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.

Files changed (67) hide show
  1. gac/__init__.py +15 -0
  2. gac/__version__.py +3 -0
  3. gac/ai.py +109 -0
  4. gac/ai_utils.py +246 -0
  5. gac/auth_cli.py +214 -0
  6. gac/cli.py +218 -0
  7. gac/commit_executor.py +62 -0
  8. gac/config.py +125 -0
  9. gac/config_cli.py +95 -0
  10. gac/constants.py +328 -0
  11. gac/diff_cli.py +159 -0
  12. gac/errors.py +231 -0
  13. gac/git.py +372 -0
  14. gac/git_state_validator.py +184 -0
  15. gac/grouped_commit_workflow.py +423 -0
  16. gac/init_cli.py +70 -0
  17. gac/interactive_mode.py +182 -0
  18. gac/language_cli.py +377 -0
  19. gac/main.py +476 -0
  20. gac/model_cli.py +430 -0
  21. gac/oauth/__init__.py +27 -0
  22. gac/oauth/claude_code.py +464 -0
  23. gac/oauth/qwen_oauth.py +327 -0
  24. gac/oauth/token_store.py +81 -0
  25. gac/preprocess.py +511 -0
  26. gac/prompt.py +878 -0
  27. gac/prompt_builder.py +88 -0
  28. gac/providers/README.md +437 -0
  29. gac/providers/__init__.py +80 -0
  30. gac/providers/anthropic.py +17 -0
  31. gac/providers/azure_openai.py +57 -0
  32. gac/providers/base.py +329 -0
  33. gac/providers/cerebras.py +15 -0
  34. gac/providers/chutes.py +25 -0
  35. gac/providers/claude_code.py +79 -0
  36. gac/providers/custom_anthropic.py +103 -0
  37. gac/providers/custom_openai.py +44 -0
  38. gac/providers/deepseek.py +15 -0
  39. gac/providers/error_handler.py +139 -0
  40. gac/providers/fireworks.py +15 -0
  41. gac/providers/gemini.py +90 -0
  42. gac/providers/groq.py +15 -0
  43. gac/providers/kimi_coding.py +27 -0
  44. gac/providers/lmstudio.py +80 -0
  45. gac/providers/minimax.py +15 -0
  46. gac/providers/mistral.py +15 -0
  47. gac/providers/moonshot.py +15 -0
  48. gac/providers/ollama.py +73 -0
  49. gac/providers/openai.py +32 -0
  50. gac/providers/openrouter.py +21 -0
  51. gac/providers/protocol.py +71 -0
  52. gac/providers/qwen.py +64 -0
  53. gac/providers/registry.py +58 -0
  54. gac/providers/replicate.py +156 -0
  55. gac/providers/streamlake.py +31 -0
  56. gac/providers/synthetic.py +40 -0
  57. gac/providers/together.py +15 -0
  58. gac/providers/zai.py +31 -0
  59. gac/py.typed +0 -0
  60. gac/security.py +293 -0
  61. gac/utils.py +401 -0
  62. gac/workflow_utils.py +217 -0
  63. gac-3.10.3.dist-info/METADATA +283 -0
  64. gac-3.10.3.dist-info/RECORD +67 -0
  65. gac-3.10.3.dist-info/WHEEL +4 -0
  66. gac-3.10.3.dist-info/entry_points.txt +2 -0
  67. 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
@@ -0,0 +1,3 @@
1
+ """Version information for gac package."""
2
+
3
+ __version__ = "3.10.3"
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.")