gac 3.6.0__py3-none-any.whl → 3.10.10__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/__init__.py +4 -6
- gac/__version__.py +1 -1
- gac/ai_utils.py +59 -43
- gac/auth_cli.py +181 -36
- gac/cli.py +26 -9
- gac/commit_executor.py +59 -0
- gac/config.py +81 -2
- gac/config_cli.py +19 -7
- gac/constants/__init__.py +34 -0
- gac/constants/commit.py +63 -0
- gac/constants/defaults.py +40 -0
- gac/constants/file_patterns.py +110 -0
- gac/constants/languages.py +119 -0
- gac/diff_cli.py +0 -22
- gac/errors.py +8 -2
- gac/git.py +6 -6
- gac/git_state_validator.py +193 -0
- gac/grouped_commit_workflow.py +458 -0
- gac/init_cli.py +2 -1
- gac/interactive_mode.py +179 -0
- gac/language_cli.py +0 -1
- gac/main.py +231 -926
- gac/model_cli.py +67 -11
- gac/model_identifier.py +70 -0
- gac/oauth/__init__.py +26 -0
- gac/oauth/claude_code.py +89 -22
- gac/oauth/qwen_oauth.py +327 -0
- gac/oauth/token_store.py +81 -0
- gac/oauth_retry.py +161 -0
- gac/postprocess.py +155 -0
- gac/prompt.py +21 -479
- gac/prompt_builder.py +88 -0
- gac/providers/README.md +437 -0
- gac/providers/__init__.py +70 -78
- gac/providers/anthropic.py +12 -46
- gac/providers/azure_openai.py +48 -88
- gac/providers/base.py +329 -0
- gac/providers/cerebras.py +10 -33
- gac/providers/chutes.py +16 -62
- gac/providers/claude_code.py +64 -87
- gac/providers/custom_anthropic.py +51 -81
- gac/providers/custom_openai.py +29 -83
- gac/providers/deepseek.py +10 -33
- gac/providers/error_handler.py +139 -0
- gac/providers/fireworks.py +10 -33
- gac/providers/gemini.py +66 -63
- gac/providers/groq.py +10 -58
- gac/providers/kimi_coding.py +19 -55
- gac/providers/lmstudio.py +64 -43
- gac/providers/minimax.py +10 -33
- gac/providers/mistral.py +10 -33
- gac/providers/moonshot.py +10 -33
- gac/providers/ollama.py +56 -33
- gac/providers/openai.py +30 -36
- gac/providers/openrouter.py +15 -52
- gac/providers/protocol.py +71 -0
- gac/providers/qwen.py +64 -0
- gac/providers/registry.py +58 -0
- gac/providers/replicate.py +140 -82
- gac/providers/streamlake.py +26 -46
- gac/providers/synthetic.py +35 -37
- gac/providers/together.py +10 -33
- gac/providers/zai.py +29 -57
- gac/py.typed +0 -0
- gac/security.py +1 -1
- gac/templates/__init__.py +1 -0
- gac/templates/question_generation.txt +60 -0
- gac/templates/system_prompt.txt +224 -0
- gac/templates/user_prompt.txt +28 -0
- gac/utils.py +36 -6
- gac/workflow_context.py +162 -0
- gac/workflow_utils.py +3 -8
- {gac-3.6.0.dist-info → gac-3.10.10.dist-info}/METADATA +6 -4
- gac-3.10.10.dist-info/RECORD +79 -0
- gac/constants.py +0 -321
- gac-3.6.0.dist-info/RECORD +0 -53
- {gac-3.6.0.dist-info → gac-3.10.10.dist-info}/WHEEL +0 -0
- {gac-3.6.0.dist-info → gac-3.10.10.dist-info}/entry_points.txt +0 -0
- {gac-3.6.0.dist-info → gac-3.10.10.dist-info}/licenses/LICENSE +0 -0
gac/__init__.py
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
"""Git Auto Commit (gac) - Generate commit messages using AI."""
|
|
2
2
|
|
|
3
|
+
from gac import init_cli
|
|
3
4
|
from gac.__version__ import __version__
|
|
4
5
|
from gac.ai import generate_commit_message
|
|
5
|
-
from gac.
|
|
6
|
-
from gac.prompt import build_prompt, clean_commit_message
|
|
6
|
+
from gac.prompt import build_prompt
|
|
7
7
|
|
|
8
8
|
__all__ = [
|
|
9
9
|
"__version__",
|
|
10
|
-
"generate_commit_message",
|
|
11
10
|
"build_prompt",
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"push_changes",
|
|
11
|
+
"generate_commit_message",
|
|
12
|
+
"init_cli",
|
|
15
13
|
]
|
gac/__version__.py
CHANGED
gac/ai_utils.py
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
This module provides utility functions that support the AI provider implementations.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import json
|
|
6
7
|
import logging
|
|
7
8
|
import os
|
|
8
9
|
import time
|
|
10
|
+
from collections.abc import Callable
|
|
9
11
|
from functools import lru_cache
|
|
10
|
-
from typing import Any
|
|
12
|
+
from typing import Any, cast
|
|
11
13
|
|
|
12
14
|
import tiktoken
|
|
13
15
|
from rich.console import Console
|
|
@@ -15,6 +17,8 @@ from rich.status import Status
|
|
|
15
17
|
|
|
16
18
|
from gac.constants import EnvDefaults, Utility
|
|
17
19
|
from gac.errors import AIError
|
|
20
|
+
from gac.oauth import QwenOAuthProvider, refresh_token_if_expired
|
|
21
|
+
from gac.oauth.token_store import TokenStore
|
|
18
22
|
from gac.providers import SUPPORTED_PROVIDERS
|
|
19
23
|
|
|
20
24
|
logger = logging.getLogger(__name__)
|
|
@@ -40,7 +44,7 @@ def count_tokens(content: str | list[dict[str, str]] | dict[str, Any], model: st
|
|
|
40
44
|
try:
|
|
41
45
|
encoding = get_encoding(model)
|
|
42
46
|
return len(encoding.encode(text))
|
|
43
|
-
except
|
|
47
|
+
except (KeyError, UnicodeError, ValueError) as e:
|
|
44
48
|
logger.error(f"Error counting tokens: {e}")
|
|
45
49
|
# Fallback to rough estimation (4 chars per token on average)
|
|
46
50
|
return len(text) // 4
|
|
@@ -53,7 +57,7 @@ def extract_text_content(content: str | list[dict[str, str]] | dict[str, Any]) -
|
|
|
53
57
|
elif isinstance(content, list):
|
|
54
58
|
return "\n".join(msg["content"] for msg in content if isinstance(msg, dict) and "content" in msg)
|
|
55
59
|
elif isinstance(content, dict) and "content" in content:
|
|
56
|
-
return content["content"]
|
|
60
|
+
return cast(str, content["content"])
|
|
57
61
|
return ""
|
|
58
62
|
|
|
59
63
|
|
|
@@ -70,36 +74,13 @@ def get_encoding(model: str) -> tiktoken.Encoding:
|
|
|
70
74
|
except KeyError:
|
|
71
75
|
# Fall back to default encoding if model not found
|
|
72
76
|
return tiktoken.get_encoding(Utility.DEFAULT_ENCODING)
|
|
73
|
-
except
|
|
77
|
+
except (OSError, ConnectionError):
|
|
74
78
|
# If there are any network/SSL issues, fall back to default encoding
|
|
75
79
|
return tiktoken.get_encoding(Utility.DEFAULT_ENCODING)
|
|
76
80
|
|
|
77
81
|
|
|
78
|
-
def _classify_error(error_str: str) -> str:
|
|
79
|
-
"""Classify error types based on error message content."""
|
|
80
|
-
error_str = error_str.lower()
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
"api key" in error_str
|
|
84
|
-
or "unauthorized" in error_str
|
|
85
|
-
or "authentication" in error_str
|
|
86
|
-
or "invalid api key" in error_str
|
|
87
|
-
):
|
|
88
|
-
return "authentication"
|
|
89
|
-
elif "timeout" in error_str or "timed out" in error_str or "request timeout" in error_str:
|
|
90
|
-
return "timeout"
|
|
91
|
-
elif "rate limit" in error_str or "too many requests" in error_str or "rate limit exceeded" in error_str:
|
|
92
|
-
return "rate_limit"
|
|
93
|
-
elif "connect" in error_str or "network" in error_str or "network connection failed" in error_str:
|
|
94
|
-
return "connection"
|
|
95
|
-
elif "model" in error_str or "not found" in error_str or "model not found" in error_str:
|
|
96
|
-
return "model"
|
|
97
|
-
else:
|
|
98
|
-
return "unknown"
|
|
99
|
-
|
|
100
|
-
|
|
101
82
|
def generate_with_retries(
|
|
102
|
-
provider_funcs: dict,
|
|
83
|
+
provider_funcs: dict[str, Callable[..., str]],
|
|
103
84
|
model: str,
|
|
104
85
|
messages: list[dict[str, str]],
|
|
105
86
|
temperature: float,
|
|
@@ -124,6 +105,48 @@ def generate_with_retries(
|
|
|
124
105
|
if not messages:
|
|
125
106
|
raise AIError.model_error("No messages provided for AI generation")
|
|
126
107
|
|
|
108
|
+
# Load Claude Code token from TokenStore if needed
|
|
109
|
+
if provider == "claude-code":
|
|
110
|
+
# Check token expiry and refresh if needed
|
|
111
|
+
if not refresh_token_if_expired(quiet=True):
|
|
112
|
+
raise AIError.authentication_error(
|
|
113
|
+
"Claude Code token not found or expired. Please authenticate with 'gac auth claude-code login'."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Load the (possibly refreshed) token
|
|
117
|
+
token_store = TokenStore()
|
|
118
|
+
token_data = token_store.get_token("claude-code")
|
|
119
|
+
if token_data and "access_token" in token_data:
|
|
120
|
+
os.environ["CLAUDE_CODE_ACCESS_TOKEN"] = token_data["access_token"]
|
|
121
|
+
else:
|
|
122
|
+
raise AIError.authentication_error(
|
|
123
|
+
"Claude Code token not found. Please authenticate with 'gac auth claude-code login'."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Check Qwen OAuth token expiry and refresh if needed
|
|
127
|
+
if provider == "qwen":
|
|
128
|
+
oauth_provider = QwenOAuthProvider(TokenStore())
|
|
129
|
+
token = oauth_provider.get_token()
|
|
130
|
+
if not token:
|
|
131
|
+
if not quiet:
|
|
132
|
+
console.print("[yellow]⚠ Qwen authentication not found or expired[/yellow]")
|
|
133
|
+
console.print("[cyan]🔐 Starting automatic authentication...[/cyan]")
|
|
134
|
+
try:
|
|
135
|
+
oauth_provider.initiate_auth(open_browser=True)
|
|
136
|
+
token = oauth_provider.get_token()
|
|
137
|
+
if not token:
|
|
138
|
+
raise AIError.authentication_error(
|
|
139
|
+
"Qwen authentication failed. Run 'gac auth qwen login' to authenticate manually."
|
|
140
|
+
)
|
|
141
|
+
if not quiet:
|
|
142
|
+
console.print("[green]✓ Authentication successful![/green]\n")
|
|
143
|
+
except AIError:
|
|
144
|
+
raise
|
|
145
|
+
except (ValueError, KeyError, json.JSONDecodeError, ConnectionError, OSError) as e:
|
|
146
|
+
raise AIError.authentication_error(
|
|
147
|
+
f"Qwen authentication failed: {e}. Run 'gac auth qwen login' to authenticate manually."
|
|
148
|
+
) from e
|
|
149
|
+
|
|
127
150
|
# Set up spinner
|
|
128
151
|
if is_group:
|
|
129
152
|
message_type = f"grouped {task_description}s"
|
|
@@ -136,7 +159,7 @@ def generate_with_retries(
|
|
|
136
159
|
spinner = Status(f"Generating {message_type} with {provider} {model_name}...")
|
|
137
160
|
spinner.start()
|
|
138
161
|
|
|
139
|
-
last_exception = None
|
|
162
|
+
last_exception: Exception | None = None
|
|
140
163
|
last_error_type = "unknown"
|
|
141
164
|
|
|
142
165
|
for attempt in range(max_retries):
|
|
@@ -161,14 +184,14 @@ def generate_with_retries(
|
|
|
161
184
|
console.print(f"✓ Generated {message_type} with {provider} {model_name}")
|
|
162
185
|
|
|
163
186
|
if content is not None and content.strip():
|
|
164
|
-
return content.strip()
|
|
187
|
+
return content.strip()
|
|
165
188
|
else:
|
|
166
189
|
logger.warning(f"Empty or None content received from {provider} {model_name}: {repr(content)}")
|
|
167
190
|
raise AIError.model_error("Empty response from AI model")
|
|
168
191
|
|
|
169
|
-
except
|
|
192
|
+
except AIError as e:
|
|
170
193
|
last_exception = e
|
|
171
|
-
error_type =
|
|
194
|
+
error_type = e.error_type
|
|
172
195
|
last_error_type = error_type
|
|
173
196
|
|
|
174
197
|
# For authentication and model errors, don't retry
|
|
@@ -176,23 +199,16 @@ def generate_with_retries(
|
|
|
176
199
|
if spinner and not skip_success_message:
|
|
177
200
|
spinner.stop()
|
|
178
201
|
console.print(f"✗ Failed to generate {message_type} with {provider} {model_name}")
|
|
179
|
-
|
|
180
|
-
# Create the appropriate error type based on classification
|
|
181
|
-
if error_type == "authentication":
|
|
182
|
-
raise AIError.authentication_error(f"AI generation failed: {str(e)}") from e
|
|
183
|
-
elif error_type == "model":
|
|
184
|
-
raise AIError.model_error(f"AI generation failed: {str(e)}") from e
|
|
202
|
+
raise
|
|
185
203
|
|
|
186
204
|
if attempt < max_retries - 1:
|
|
187
205
|
# Exponential backoff
|
|
188
206
|
wait_time = 2**attempt
|
|
189
207
|
if not quiet and not skip_success_message:
|
|
190
208
|
if attempt == 0:
|
|
191
|
-
logger.warning(f"AI generation failed, retrying in {wait_time}s: {
|
|
209
|
+
logger.warning(f"AI generation failed, retrying in {wait_time}s: {e}")
|
|
192
210
|
else:
|
|
193
|
-
logger.warning(
|
|
194
|
-
f"AI generation failed (attempt {attempt + 1}), retrying in {wait_time}s: {str(e)}"
|
|
195
|
-
)
|
|
211
|
+
logger.warning(f"AI generation failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}")
|
|
196
212
|
|
|
197
213
|
if spinner and not skip_success_message:
|
|
198
214
|
for i in range(wait_time, 0, -1):
|
|
@@ -203,7 +219,7 @@ def generate_with_retries(
|
|
|
203
219
|
else:
|
|
204
220
|
num_retries = max_retries
|
|
205
221
|
retry_word = "retry" if num_retries == 1 else "retries"
|
|
206
|
-
logger.error(f"AI generation failed after {num_retries} {retry_word}: {
|
|
222
|
+
logger.error(f"AI generation failed after {num_retries} {retry_word}: {e}")
|
|
207
223
|
|
|
208
224
|
if spinner and not skip_success_message:
|
|
209
225
|
spinner.stop()
|
gac/auth_cli.py
CHANGED
|
@@ -1,54 +1,95 @@
|
|
|
1
|
-
"""CLI for
|
|
1
|
+
"""CLI for OAuth authentication with various providers.
|
|
2
2
|
|
|
3
|
-
Provides
|
|
3
|
+
Provides commands to authenticate and manage OAuth tokens for supported providers.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import logging
|
|
7
7
|
|
|
8
8
|
import click
|
|
9
9
|
|
|
10
|
-
from gac.oauth
|
|
10
|
+
from gac.oauth import (
|
|
11
|
+
QwenOAuthProvider,
|
|
12
|
+
TokenStore,
|
|
13
|
+
authenticate_and_save,
|
|
14
|
+
remove_token,
|
|
15
|
+
)
|
|
11
16
|
from gac.utils import setup_logging
|
|
12
17
|
|
|
13
18
|
logger = logging.getLogger(__name__)
|
|
14
19
|
|
|
15
20
|
|
|
16
|
-
@click.
|
|
17
|
-
@click.
|
|
18
|
-
|
|
19
|
-
"
|
|
20
|
-
is_flag=True,
|
|
21
|
-
help="Suppress non-error output",
|
|
22
|
-
)
|
|
23
|
-
@click.option(
|
|
24
|
-
"--log-level",
|
|
25
|
-
default="INFO",
|
|
26
|
-
type=click.Choice(["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], case_sensitive=False),
|
|
27
|
-
help="Set log level (default: INFO)",
|
|
28
|
-
)
|
|
29
|
-
def auth(quiet: bool = False, log_level: str = "INFO") -> None:
|
|
30
|
-
"""Authenticate Claude Code OAuth token.
|
|
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.
|
|
31
25
|
|
|
32
|
-
|
|
33
|
-
Claude Code OAuth
|
|
34
|
-
|
|
35
|
-
to ~/.gac.env.
|
|
26
|
+
Supports authentication for:
|
|
27
|
+
- claude-code: Claude Code subscription OAuth
|
|
28
|
+
- qwen: Qwen AI OAuth (device flow)
|
|
36
29
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
39
38
|
"""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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")
|
|
43
53
|
else:
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
46
73
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
52
93
|
|
|
53
94
|
if not quiet:
|
|
54
95
|
click.echo("🔐 Starting Claude Code OAuth authentication...")
|
|
@@ -56,14 +97,118 @@ def auth(quiet: bool = False, log_level: str = "INFO") -> None:
|
|
|
56
97
|
click.echo(" (Waiting up to 3 minutes for callback)")
|
|
57
98
|
click.echo()
|
|
58
99
|
|
|
59
|
-
# Perform OAuth authentication
|
|
60
100
|
success = authenticate_and_save(quiet=quiet)
|
|
61
101
|
|
|
62
102
|
if success:
|
|
63
103
|
if not quiet:
|
|
104
|
+
click.echo()
|
|
64
105
|
click.echo("✅ Claude Code authentication completed successfully!")
|
|
65
|
-
click.echo(" Your new token has been saved and is ready to use.")
|
|
66
106
|
else:
|
|
67
107
|
click.echo("❌ Claude Code authentication failed.")
|
|
68
108
|
click.echo(" Please try again or check your network connection.")
|
|
69
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.")
|
gac/cli.py
CHANGED
|
@@ -6,14 +6,16 @@ Defines the Click-based command-line interface and delegates execution to the ma
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
|
+
import os
|
|
9
10
|
import sys
|
|
11
|
+
from typing import Any
|
|
10
12
|
|
|
11
13
|
import click
|
|
12
14
|
from rich.console import Console
|
|
13
15
|
|
|
14
16
|
from gac import __version__
|
|
15
17
|
from gac.auth_cli import auth as auth_cli
|
|
16
|
-
from gac.config import load_config
|
|
18
|
+
from gac.config import GACConfig, load_config
|
|
17
19
|
from gac.config_cli import config as config_cli
|
|
18
20
|
from gac.constants import Languages, Logging
|
|
19
21
|
from gac.diff_cli import diff as diff_cli
|
|
@@ -23,8 +25,9 @@ from gac.language_cli import language as language_cli
|
|
|
23
25
|
from gac.main import main
|
|
24
26
|
from gac.model_cli import model as model_cli
|
|
25
27
|
from gac.utils import setup_logging
|
|
28
|
+
from gac.workflow_context import CLIOptions
|
|
26
29
|
|
|
27
|
-
config = load_config()
|
|
30
|
+
config: GACConfig = load_config()
|
|
28
31
|
logger = logging.getLogger(__name__)
|
|
29
32
|
console = Console()
|
|
30
33
|
|
|
@@ -73,6 +76,11 @@ console = Console()
|
|
|
73
76
|
# Advanced options
|
|
74
77
|
@click.option("--no-verify", is_flag=True, help="Skip pre-commit and lefthook hooks when committing")
|
|
75
78
|
@click.option("--skip-secret-scan", is_flag=True, help="Skip security scan for secrets in staged changes")
|
|
79
|
+
@click.option(
|
|
80
|
+
"--no-verify-ssl",
|
|
81
|
+
is_flag=True,
|
|
82
|
+
help="Skip SSL certificate verification (useful for corporate proxies)",
|
|
83
|
+
)
|
|
76
84
|
@click.option(
|
|
77
85
|
"--hook-timeout",
|
|
78
86
|
type=int,
|
|
@@ -103,6 +111,7 @@ def cli(
|
|
|
103
111
|
verbose: bool = False,
|
|
104
112
|
no_verify: bool = False,
|
|
105
113
|
skip_secret_scan: bool = False,
|
|
114
|
+
no_verify_ssl: bool = False,
|
|
106
115
|
hook_timeout: int = 0,
|
|
107
116
|
) -> None:
|
|
108
117
|
"""Git Auto Commit - Generate commit messages with AI."""
|
|
@@ -116,6 +125,11 @@ def cli(
|
|
|
116
125
|
setup_logging(effective_log_level)
|
|
117
126
|
logger.info("Starting gac")
|
|
118
127
|
|
|
128
|
+
# Set SSL verification environment variable if flag is used or config is set
|
|
129
|
+
if no_verify_ssl or config["no_verify_ssl"]:
|
|
130
|
+
os.environ["GAC_NO_VERIFY_SSL"] = "true"
|
|
131
|
+
logger.info("SSL certificate verification disabled")
|
|
132
|
+
|
|
119
133
|
# Validate incompatible flag combinations
|
|
120
134
|
if message_only and group:
|
|
121
135
|
console.print("[red]Error: --message-only and --group options are mutually exclusive[/red]")
|
|
@@ -124,16 +138,16 @@ def cli(
|
|
|
124
138
|
sys.exit(1)
|
|
125
139
|
|
|
126
140
|
# Determine if we should infer scope based on -s flag or always_include_scope setting
|
|
127
|
-
infer_scope = bool(scope or config
|
|
141
|
+
infer_scope = bool(scope or config["always_include_scope"])
|
|
128
142
|
|
|
129
143
|
# Determine if verbose mode should be enabled based on -v flag or verbose config setting
|
|
130
|
-
use_verbose = bool(verbose or config
|
|
144
|
+
use_verbose = bool(verbose or config["verbose"])
|
|
131
145
|
|
|
132
146
|
# Resolve language code to full name if provided
|
|
133
147
|
resolved_language = Languages.resolve_code(language) if language else None
|
|
134
148
|
|
|
135
149
|
try:
|
|
136
|
-
|
|
150
|
+
opts = CLIOptions(
|
|
137
151
|
stage_all=add_all,
|
|
138
152
|
group=group,
|
|
139
153
|
interactive=interactive,
|
|
@@ -149,15 +163,17 @@ def cli(
|
|
|
149
163
|
message_only=message_only,
|
|
150
164
|
verbose=use_verbose,
|
|
151
165
|
no_verify=no_verify,
|
|
152
|
-
skip_secret_scan=skip_secret_scan or
|
|
166
|
+
skip_secret_scan=skip_secret_scan or config["skip_secret_scan"],
|
|
153
167
|
language=resolved_language,
|
|
154
|
-
hook_timeout=hook_timeout if hook_timeout > 0 else
|
|
168
|
+
hook_timeout=hook_timeout if hook_timeout > 0 else config["hook_timeout"],
|
|
155
169
|
)
|
|
170
|
+
exit_code = main(opts)
|
|
171
|
+
sys.exit(exit_code)
|
|
156
172
|
except Exception as e:
|
|
157
173
|
handle_error(e, exit_program=True)
|
|
158
174
|
else:
|
|
159
175
|
# Determine if we should infer scope based on -s flag or always_include_scope setting
|
|
160
|
-
infer_scope = bool(scope or config
|
|
176
|
+
infer_scope = bool(scope or config["always_include_scope"])
|
|
161
177
|
|
|
162
178
|
ctx.obj = {
|
|
163
179
|
"add_all": add_all,
|
|
@@ -179,6 +195,7 @@ def cli(
|
|
|
179
195
|
"verbose": verbose,
|
|
180
196
|
"no_verify": no_verify,
|
|
181
197
|
"skip_secret_scan": skip_secret_scan,
|
|
198
|
+
"no_verify_ssl": no_verify_ssl,
|
|
182
199
|
"hook_timeout": hook_timeout,
|
|
183
200
|
}
|
|
184
201
|
|
|
@@ -193,7 +210,7 @@ cli.add_command(model_cli)
|
|
|
193
210
|
|
|
194
211
|
@click.command(context_settings=language_cli.context_settings)
|
|
195
212
|
@click.pass_context
|
|
196
|
-
def lang(ctx):
|
|
213
|
+
def lang(ctx: Any) -> None:
|
|
197
214
|
"""Set the language for commit messages interactively. (Alias for 'language')"""
|
|
198
215
|
ctx.forward(language_cli)
|
|
199
216
|
|
gac/commit_executor.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Commit execution logic for gac."""
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from gac.errors import GitError
|
|
9
|
+
from gac.git import get_staged_files, push_changes
|
|
10
|
+
from gac.workflow_utils import execute_commit
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CommitExecutor:
|
|
17
|
+
"""Handles commit creation and related operations."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, dry_run: bool = False, quiet: bool = False, no_verify: bool = False, hook_timeout: int = 120):
|
|
20
|
+
self.dry_run = dry_run
|
|
21
|
+
self.quiet = quiet
|
|
22
|
+
self.no_verify = no_verify
|
|
23
|
+
self.hook_timeout = hook_timeout
|
|
24
|
+
|
|
25
|
+
def create_commit(self, commit_message: str) -> None:
|
|
26
|
+
"""Create a single commit with the given message."""
|
|
27
|
+
if self.dry_run:
|
|
28
|
+
console.print("[yellow]Dry run: Commit message generated but not applied[/yellow]")
|
|
29
|
+
console.print("Would commit with message:")
|
|
30
|
+
from rich.panel import Panel
|
|
31
|
+
|
|
32
|
+
console.print(Panel(commit_message, title="Commit Message", border_style="cyan"))
|
|
33
|
+
staged_files = get_staged_files(existing_only=False)
|
|
34
|
+
console.print(f"Would commit {len(staged_files)} files")
|
|
35
|
+
logger.info(f"Would commit {len(staged_files)} files")
|
|
36
|
+
else:
|
|
37
|
+
execute_commit(commit_message, self.no_verify, self.hook_timeout)
|
|
38
|
+
|
|
39
|
+
def push_to_remote(self) -> None:
|
|
40
|
+
"""Push changes to remote repository.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
GitError: If push fails or remote is not configured.
|
|
44
|
+
"""
|
|
45
|
+
if self.dry_run:
|
|
46
|
+
staged_files = get_staged_files(existing_only=False)
|
|
47
|
+
logger.info("Dry run: Would push changes")
|
|
48
|
+
logger.info(f"Would push {len(staged_files)} files")
|
|
49
|
+
console.print("[yellow]Dry run: Would push changes[/yellow]")
|
|
50
|
+
console.print(f"Would push {len(staged_files)} files")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
if push_changes():
|
|
54
|
+
logger.info("Changes pushed successfully")
|
|
55
|
+
if not self.quiet:
|
|
56
|
+
console.print("[green]Changes pushed successfully[/green]")
|
|
57
|
+
else:
|
|
58
|
+
console.print("[red]Failed to push changes. Check your remote configuration and network connection.[/red]")
|
|
59
|
+
raise GitError("Failed to push changes")
|