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.
Files changed (54) hide show
  1. gac/__version__.py +1 -1
  2. gac/ai.py +33 -47
  3. gac/ai_utils.py +113 -41
  4. gac/auth_cli.py +214 -0
  5. gac/cli.py +72 -2
  6. gac/config.py +63 -6
  7. gac/config_cli.py +26 -5
  8. gac/constants.py +178 -2
  9. gac/git.py +158 -12
  10. gac/init_cli.py +40 -125
  11. gac/language_cli.py +378 -0
  12. gac/main.py +868 -158
  13. gac/model_cli.py +429 -0
  14. gac/oauth/__init__.py +27 -0
  15. gac/oauth/claude_code.py +464 -0
  16. gac/oauth/qwen_oauth.py +323 -0
  17. gac/oauth/token_store.py +81 -0
  18. gac/preprocess.py +3 -3
  19. gac/prompt.py +573 -226
  20. gac/providers/__init__.py +49 -0
  21. gac/providers/anthropic.py +11 -1
  22. gac/providers/azure_openai.py +101 -0
  23. gac/providers/cerebras.py +11 -1
  24. gac/providers/chutes.py +11 -1
  25. gac/providers/claude_code.py +112 -0
  26. gac/providers/custom_anthropic.py +6 -2
  27. gac/providers/custom_openai.py +6 -3
  28. gac/providers/deepseek.py +11 -1
  29. gac/providers/fireworks.py +11 -1
  30. gac/providers/gemini.py +11 -1
  31. gac/providers/groq.py +5 -1
  32. gac/providers/kimi_coding.py +67 -0
  33. gac/providers/lmstudio.py +12 -1
  34. gac/providers/minimax.py +11 -1
  35. gac/providers/mistral.py +48 -0
  36. gac/providers/moonshot.py +48 -0
  37. gac/providers/ollama.py +11 -1
  38. gac/providers/openai.py +11 -1
  39. gac/providers/openrouter.py +11 -1
  40. gac/providers/qwen.py +76 -0
  41. gac/providers/replicate.py +110 -0
  42. gac/providers/streamlake.py +11 -1
  43. gac/providers/synthetic.py +11 -1
  44. gac/providers/together.py +11 -1
  45. gac/providers/zai.py +11 -1
  46. gac/security.py +1 -1
  47. gac/utils.py +272 -4
  48. gac/workflow_utils.py +217 -0
  49. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/METADATA +90 -27
  50. gac-3.8.1.dist-info/RECORD +56 -0
  51. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/WHEEL +1 -1
  52. gac-1.13.0.dist-info/RECORD +0 -41
  53. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/entry_points.txt +0 -0
  54. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/licenses/LICENSE +0 -0
gac/__version__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Version information for gac package."""
2
2
 
3
- __version__ = "1.13.0"
3
+ __version__ = "3.8.1"
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-3-5-haiku-latest')
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-3-5-haiku-latest"
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=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 halo import Halo
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(":")[-1] if ":" in model else model
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
- supported_providers = [
96
- "anthropic",
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 = Halo(text=f"Generating commit message with {provider} {model_name}...", spinner="dots")
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.text = f"Retry {attempt + 1}/{max_retries} with {provider} {model_name}..."
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
- spinner.succeed(f"Generated commit message with {provider} {model_name}")
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.fail(f"Failed to generate commit message with {provider} {model_name}")
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
- logger.warning(f"AI generation failed (attempt {attempt + 1}), retrying in {wait_time}s: {str(e)}")
176
-
177
- if spinner:
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.text = f"Retry {attempt + 1}/{max_retries} in {i}s..."
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
- logger.error(f"AI generation failed after {max_retries} attempts: {str(e)}")
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.fail(f"Failed to generate commit message with {provider} {model_name}")
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
- error_message = f"Failed to generate commit message after {max_retries} attempts"
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.")