gac 3.10.3__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.
Potentially problematic release.
This version of gac might be problematic. Click here for more details.
- gac/__init__.py +15 -0
- gac/__version__.py +3 -0
- gac/ai.py +109 -0
- gac/ai_utils.py +246 -0
- gac/auth_cli.py +214 -0
- gac/cli.py +218 -0
- gac/commit_executor.py +62 -0
- gac/config.py +125 -0
- gac/config_cli.py +95 -0
- gac/constants.py +328 -0
- gac/diff_cli.py +159 -0
- gac/errors.py +231 -0
- gac/git.py +372 -0
- gac/git_state_validator.py +184 -0
- gac/grouped_commit_workflow.py +423 -0
- gac/init_cli.py +70 -0
- gac/interactive_mode.py +182 -0
- gac/language_cli.py +377 -0
- gac/main.py +476 -0
- gac/model_cli.py +430 -0
- gac/oauth/__init__.py +27 -0
- gac/oauth/claude_code.py +464 -0
- gac/oauth/qwen_oauth.py +327 -0
- gac/oauth/token_store.py +81 -0
- gac/preprocess.py +511 -0
- gac/prompt.py +878 -0
- gac/prompt_builder.py +88 -0
- gac/providers/README.md +437 -0
- gac/providers/__init__.py +80 -0
- gac/providers/anthropic.py +17 -0
- gac/providers/azure_openai.py +57 -0
- gac/providers/base.py +329 -0
- gac/providers/cerebras.py +15 -0
- gac/providers/chutes.py +25 -0
- gac/providers/claude_code.py +79 -0
- gac/providers/custom_anthropic.py +103 -0
- gac/providers/custom_openai.py +44 -0
- gac/providers/deepseek.py +15 -0
- gac/providers/error_handler.py +139 -0
- gac/providers/fireworks.py +15 -0
- gac/providers/gemini.py +90 -0
- gac/providers/groq.py +15 -0
- gac/providers/kimi_coding.py +27 -0
- gac/providers/lmstudio.py +80 -0
- gac/providers/minimax.py +15 -0
- gac/providers/mistral.py +15 -0
- gac/providers/moonshot.py +15 -0
- gac/providers/ollama.py +73 -0
- gac/providers/openai.py +32 -0
- gac/providers/openrouter.py +21 -0
- gac/providers/protocol.py +71 -0
- gac/providers/qwen.py +64 -0
- gac/providers/registry.py +58 -0
- gac/providers/replicate.py +156 -0
- gac/providers/streamlake.py +31 -0
- gac/providers/synthetic.py +40 -0
- gac/providers/together.py +15 -0
- gac/providers/zai.py +31 -0
- gac/py.typed +0 -0
- gac/security.py +293 -0
- gac/utils.py +401 -0
- gac/workflow_utils.py +217 -0
- gac-3.10.3.dist-info/METADATA +283 -0
- gac-3.10.3.dist-info/RECORD +67 -0
- gac-3.10.3.dist-info/WHEEL +4 -0
- gac-3.10.3.dist-info/entry_points.txt +2 -0
- gac-3.10.3.dist-info/licenses/LICENSE +16 -0
gac/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Git Auto Commit (gac) - Generate commit messages using AI."""
|
|
2
|
+
|
|
3
|
+
from gac.__version__ import __version__
|
|
4
|
+
from gac.ai import generate_commit_message
|
|
5
|
+
from gac.git import get_staged_files, push_changes
|
|
6
|
+
from gac.prompt import build_prompt, clean_commit_message
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"__version__",
|
|
10
|
+
"generate_commit_message",
|
|
11
|
+
"build_prompt",
|
|
12
|
+
"clean_commit_message",
|
|
13
|
+
"get_staged_files",
|
|
14
|
+
"push_changes",
|
|
15
|
+
]
|
gac/__version__.py
ADDED
gac/ai.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""AI provider integration for gac.
|
|
2
|
+
|
|
3
|
+
This module provides core functionality for AI provider interaction.
|
|
4
|
+
It consolidates all AI-related functionality including token counting and commit message generation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from gac.ai_utils import generate_with_retries
|
|
10
|
+
from gac.constants import EnvDefaults
|
|
11
|
+
from gac.errors import AIError
|
|
12
|
+
from gac.providers import PROVIDER_REGISTRY
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def generate_commit_message(
|
|
18
|
+
model: str,
|
|
19
|
+
prompt: str | tuple[str, str] | list[dict[str, str]],
|
|
20
|
+
temperature: float = EnvDefaults.TEMPERATURE,
|
|
21
|
+
max_tokens: int = EnvDefaults.MAX_OUTPUT_TOKENS,
|
|
22
|
+
max_retries: int = EnvDefaults.MAX_RETRIES,
|
|
23
|
+
quiet: bool = False,
|
|
24
|
+
is_group: bool = False,
|
|
25
|
+
skip_success_message: bool = False,
|
|
26
|
+
task_description: str = "commit message",
|
|
27
|
+
) -> str:
|
|
28
|
+
"""Generate a commit message using direct API calls to AI providers.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
model: The model to use in provider:model_name format (e.g., 'anthropic:claude-haiku-4-5')
|
|
32
|
+
prompt: Either a string prompt (for backward compatibility) or tuple of (system_prompt, user_prompt)
|
|
33
|
+
temperature: Controls randomness (0.0-1.0), lower values are more deterministic
|
|
34
|
+
max_tokens: Maximum tokens in the response
|
|
35
|
+
max_retries: Number of retry attempts if generation fails
|
|
36
|
+
quiet: If True, suppress progress indicators
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A formatted commit message string
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
AIError: If generation fails after max_retries attempts
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
>>> model = "anthropic:claude-haiku-4-5"
|
|
46
|
+
>>> system_prompt, user_prompt = build_prompt("On branch main", "diff --git a/README.md b/README.md")
|
|
47
|
+
>>> generate_commit_message(model, (system_prompt, user_prompt))
|
|
48
|
+
'docs: Update README with installation instructions'
|
|
49
|
+
"""
|
|
50
|
+
# Handle both old (string) and new (tuple) prompt formats
|
|
51
|
+
if isinstance(prompt, list):
|
|
52
|
+
messages = [{**msg} for msg in prompt]
|
|
53
|
+
elif isinstance(prompt, tuple):
|
|
54
|
+
system_prompt, user_prompt = prompt
|
|
55
|
+
messages = [
|
|
56
|
+
{"role": "system", "content": system_prompt or ""},
|
|
57
|
+
{"role": "user", "content": user_prompt},
|
|
58
|
+
]
|
|
59
|
+
else:
|
|
60
|
+
# Backward compatibility: treat string as user prompt with empty system prompt
|
|
61
|
+
user_prompt = str(prompt)
|
|
62
|
+
messages = [
|
|
63
|
+
{"role": "system", "content": ""},
|
|
64
|
+
{"role": "user", "content": user_prompt},
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
# Generate the commit message using centralized retry logic
|
|
68
|
+
try:
|
|
69
|
+
return generate_with_retries(
|
|
70
|
+
provider_funcs=PROVIDER_REGISTRY,
|
|
71
|
+
model=model,
|
|
72
|
+
messages=messages,
|
|
73
|
+
temperature=temperature,
|
|
74
|
+
max_tokens=max_tokens,
|
|
75
|
+
max_retries=max_retries,
|
|
76
|
+
quiet=quiet,
|
|
77
|
+
is_group=is_group,
|
|
78
|
+
skip_success_message=skip_success_message,
|
|
79
|
+
task_description=task_description,
|
|
80
|
+
)
|
|
81
|
+
except AIError:
|
|
82
|
+
# Re-raise AIError exceptions as-is to preserve error classification
|
|
83
|
+
raise
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error(f"Failed to generate commit message: {e}")
|
|
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
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Utilities for AI provider integration for gac.
|
|
2
|
+
|
|
3
|
+
This module provides utility functions that support the AI provider implementations.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
import tiktoken
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.status import Status
|
|
17
|
+
|
|
18
|
+
from gac.constants import EnvDefaults, Utility
|
|
19
|
+
from gac.errors import AIError
|
|
20
|
+
from gac.providers import SUPPORTED_PROVIDERS
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@lru_cache(maxsize=1)
|
|
27
|
+
def _should_skip_tiktoken_counting() -> bool:
|
|
28
|
+
"""Return True when token counting should avoid tiktoken calls entirely."""
|
|
29
|
+
value = os.getenv("GAC_NO_TIKTOKEN", str(EnvDefaults.NO_TIKTOKEN))
|
|
30
|
+
return value.lower() in ("true", "1", "yes", "on")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def count_tokens(content: str | list[dict[str, str]] | dict[str, Any], model: str) -> int:
|
|
34
|
+
"""Count tokens in content using the model's tokenizer."""
|
|
35
|
+
text = extract_text_content(content)
|
|
36
|
+
if not text:
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
if _should_skip_tiktoken_counting():
|
|
40
|
+
return len(text) // 4
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
encoding = get_encoding(model)
|
|
44
|
+
return len(encoding.encode(text))
|
|
45
|
+
except (KeyError, UnicodeError, ValueError) as e:
|
|
46
|
+
logger.error(f"Error counting tokens: {e}")
|
|
47
|
+
# Fallback to rough estimation (4 chars per token on average)
|
|
48
|
+
return len(text) // 4
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extract_text_content(content: str | list[dict[str, str]] | dict[str, Any]) -> str:
|
|
52
|
+
"""Extract text content from various input formats."""
|
|
53
|
+
if isinstance(content, str):
|
|
54
|
+
return content
|
|
55
|
+
elif isinstance(content, list):
|
|
56
|
+
return "\n".join(msg["content"] for msg in content if isinstance(msg, dict) and "content" in msg)
|
|
57
|
+
elif isinstance(content, dict) and "content" in content:
|
|
58
|
+
return cast(str, content["content"])
|
|
59
|
+
return ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@lru_cache(maxsize=1)
|
|
63
|
+
def get_encoding(model: str) -> tiktoken.Encoding:
|
|
64
|
+
"""Get the appropriate encoding for a given model."""
|
|
65
|
+
provider, model_name = model.split(":", 1) if ":" in model else (None, model)
|
|
66
|
+
|
|
67
|
+
if provider != "openai":
|
|
68
|
+
return tiktoken.get_encoding(Utility.DEFAULT_ENCODING)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
return tiktoken.encoding_for_model(model_name)
|
|
72
|
+
except KeyError:
|
|
73
|
+
# Fall back to default encoding if model not found
|
|
74
|
+
return tiktoken.get_encoding(Utility.DEFAULT_ENCODING)
|
|
75
|
+
except (OSError, ConnectionError):
|
|
76
|
+
# If there are any network/SSL issues, fall back to default encoding
|
|
77
|
+
return tiktoken.get_encoding(Utility.DEFAULT_ENCODING)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def generate_with_retries(
|
|
81
|
+
provider_funcs: dict[str, Callable[..., str]],
|
|
82
|
+
model: str,
|
|
83
|
+
messages: list[dict[str, str]],
|
|
84
|
+
temperature: float,
|
|
85
|
+
max_tokens: int,
|
|
86
|
+
max_retries: int,
|
|
87
|
+
quiet: bool = False,
|
|
88
|
+
is_group: bool = False,
|
|
89
|
+
skip_success_message: bool = False,
|
|
90
|
+
task_description: str = "commit message",
|
|
91
|
+
) -> str:
|
|
92
|
+
"""Generate content with retry logic using direct API calls."""
|
|
93
|
+
# Parse model string to determine provider and actual model
|
|
94
|
+
if ":" not in model:
|
|
95
|
+
raise AIError.model_error(f"Invalid model format. Expected 'provider:model', got '{model}'")
|
|
96
|
+
|
|
97
|
+
provider, model_name = model.split(":", 1)
|
|
98
|
+
|
|
99
|
+
# Validate provider
|
|
100
|
+
if provider not in SUPPORTED_PROVIDERS:
|
|
101
|
+
raise AIError.model_error(f"Unsupported provider: {provider}. Supported providers: {SUPPORTED_PROVIDERS}")
|
|
102
|
+
|
|
103
|
+
if not messages:
|
|
104
|
+
raise AIError.model_error("No messages provided for AI generation")
|
|
105
|
+
|
|
106
|
+
# Load Claude Code token from TokenStore if needed
|
|
107
|
+
if provider == "claude-code":
|
|
108
|
+
from gac.oauth import refresh_token_if_expired
|
|
109
|
+
from gac.oauth.token_store import TokenStore
|
|
110
|
+
|
|
111
|
+
# Check token expiry and refresh if needed
|
|
112
|
+
if not refresh_token_if_expired(quiet=True):
|
|
113
|
+
raise AIError.authentication_error(
|
|
114
|
+
"Claude Code token not found or expired. Please authenticate with 'gac auth claude-code login'."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Load the (possibly refreshed) token
|
|
118
|
+
token_store = TokenStore()
|
|
119
|
+
token_data = token_store.get_token("claude-code")
|
|
120
|
+
if token_data and "access_token" in token_data:
|
|
121
|
+
os.environ["CLAUDE_CODE_ACCESS_TOKEN"] = token_data["access_token"]
|
|
122
|
+
else:
|
|
123
|
+
raise AIError.authentication_error(
|
|
124
|
+
"Claude Code token not found. Please authenticate with 'gac auth claude-code login'."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Check Qwen OAuth token expiry and refresh if needed
|
|
128
|
+
if provider == "qwen":
|
|
129
|
+
from gac.oauth import QwenOAuthProvider, TokenStore
|
|
130
|
+
|
|
131
|
+
oauth_provider = QwenOAuthProvider(TokenStore())
|
|
132
|
+
token = oauth_provider.get_token()
|
|
133
|
+
if not token:
|
|
134
|
+
if not quiet:
|
|
135
|
+
console.print("[yellow]⚠ Qwen authentication not found or expired[/yellow]")
|
|
136
|
+
console.print("[cyan]🔐 Starting automatic authentication...[/cyan]")
|
|
137
|
+
try:
|
|
138
|
+
oauth_provider.initiate_auth(open_browser=True)
|
|
139
|
+
token = oauth_provider.get_token()
|
|
140
|
+
if not token:
|
|
141
|
+
raise AIError.authentication_error(
|
|
142
|
+
"Qwen authentication failed. Run 'gac auth qwen login' to authenticate manually."
|
|
143
|
+
)
|
|
144
|
+
if not quiet:
|
|
145
|
+
console.print("[green]✓ Authentication successful![/green]\n")
|
|
146
|
+
except AIError:
|
|
147
|
+
raise
|
|
148
|
+
except (ValueError, KeyError, json.JSONDecodeError, ConnectionError, OSError) as e:
|
|
149
|
+
raise AIError.authentication_error(
|
|
150
|
+
f"Qwen authentication failed: {e}. Run 'gac auth qwen login' to authenticate manually."
|
|
151
|
+
) from e
|
|
152
|
+
|
|
153
|
+
# Set up spinner
|
|
154
|
+
if is_group:
|
|
155
|
+
message_type = f"grouped {task_description}s"
|
|
156
|
+
else:
|
|
157
|
+
message_type = task_description
|
|
158
|
+
|
|
159
|
+
if quiet:
|
|
160
|
+
spinner = None
|
|
161
|
+
else:
|
|
162
|
+
spinner = Status(f"Generating {message_type} with {provider} {model_name}...")
|
|
163
|
+
spinner.start()
|
|
164
|
+
|
|
165
|
+
last_exception: Exception | None = None
|
|
166
|
+
last_error_type = "unknown"
|
|
167
|
+
|
|
168
|
+
for attempt in range(max_retries):
|
|
169
|
+
try:
|
|
170
|
+
if not quiet and not skip_success_message and attempt > 0:
|
|
171
|
+
if spinner:
|
|
172
|
+
spinner.update(f"Retry {attempt + 1}/{max_retries} with {provider} {model_name}...")
|
|
173
|
+
logger.info(f"Retry attempt {attempt + 1}/{max_retries}")
|
|
174
|
+
|
|
175
|
+
# Call the appropriate provider function
|
|
176
|
+
provider_func = provider_funcs.get(provider)
|
|
177
|
+
if not provider_func:
|
|
178
|
+
raise AIError.model_error(f"Provider function not found for: {provider}")
|
|
179
|
+
|
|
180
|
+
content = provider_func(model=model_name, messages=messages, temperature=temperature, max_tokens=max_tokens)
|
|
181
|
+
|
|
182
|
+
if spinner:
|
|
183
|
+
if skip_success_message:
|
|
184
|
+
spinner.stop() # Stop spinner without showing success/failure
|
|
185
|
+
else:
|
|
186
|
+
spinner.stop()
|
|
187
|
+
console.print(f"✓ Generated {message_type} with {provider} {model_name}")
|
|
188
|
+
|
|
189
|
+
if content is not None and content.strip():
|
|
190
|
+
return content.strip()
|
|
191
|
+
else:
|
|
192
|
+
logger.warning(f"Empty or None content received from {provider} {model_name}: {repr(content)}")
|
|
193
|
+
raise AIError.model_error("Empty response from AI model")
|
|
194
|
+
|
|
195
|
+
except AIError as e:
|
|
196
|
+
last_exception = e
|
|
197
|
+
error_type = e.error_type
|
|
198
|
+
last_error_type = error_type
|
|
199
|
+
|
|
200
|
+
# For authentication and model errors, don't retry
|
|
201
|
+
if error_type in ["authentication", "model"]:
|
|
202
|
+
if spinner and not skip_success_message:
|
|
203
|
+
spinner.stop()
|
|
204
|
+
console.print(f"✗ Failed to generate {message_type} with {provider} {model_name}")
|
|
205
|
+
raise
|
|
206
|
+
|
|
207
|
+
if attempt < max_retries - 1:
|
|
208
|
+
# Exponential backoff
|
|
209
|
+
wait_time = 2**attempt
|
|
210
|
+
if not quiet and not skip_success_message:
|
|
211
|
+
if attempt == 0:
|
|
212
|
+
logger.warning(f"AI generation failed, retrying in {wait_time}s: {e}")
|
|
213
|
+
else:
|
|
214
|
+
logger.warning(f"AI generation failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}")
|
|
215
|
+
|
|
216
|
+
if spinner and not skip_success_message:
|
|
217
|
+
for i in range(wait_time, 0, -1):
|
|
218
|
+
spinner.update(f"Retry {attempt + 1}/{max_retries} in {i}s...")
|
|
219
|
+
time.sleep(1)
|
|
220
|
+
else:
|
|
221
|
+
time.sleep(wait_time)
|
|
222
|
+
else:
|
|
223
|
+
num_retries = max_retries
|
|
224
|
+
retry_word = "retry" if num_retries == 1 else "retries"
|
|
225
|
+
logger.error(f"AI generation failed after {num_retries} {retry_word}: {e}")
|
|
226
|
+
|
|
227
|
+
if spinner and not skip_success_message:
|
|
228
|
+
spinner.stop()
|
|
229
|
+
console.print(f"✗ Failed to generate {message_type} with {provider} {model_name}")
|
|
230
|
+
|
|
231
|
+
# If we get here, all retries failed - use the last classified error type
|
|
232
|
+
num_retries = max_retries
|
|
233
|
+
retry_word = "retry" if num_retries == 1 else "retries"
|
|
234
|
+
error_message = f"Failed to generate {message_type} after {num_retries} {retry_word}"
|
|
235
|
+
if last_error_type == "authentication":
|
|
236
|
+
raise AIError.authentication_error(error_message) from last_exception
|
|
237
|
+
elif last_error_type == "rate_limit":
|
|
238
|
+
raise AIError.rate_limit_error(error_message) from last_exception
|
|
239
|
+
elif last_error_type == "timeout":
|
|
240
|
+
raise AIError.timeout_error(error_message) from last_exception
|
|
241
|
+
elif last_error_type == "connection":
|
|
242
|
+
raise AIError.connection_error(error_message) from last_exception
|
|
243
|
+
elif last_error_type == "model":
|
|
244
|
+
raise AIError.model_error(error_message) from last_exception
|
|
245
|
+
else:
|
|
246
|
+
raise AIError.unknown_error(error_message) from last_exception
|
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.")
|