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.
Files changed (79) hide show
  1. gac/__init__.py +4 -6
  2. gac/__version__.py +1 -1
  3. gac/ai_utils.py +59 -43
  4. gac/auth_cli.py +181 -36
  5. gac/cli.py +26 -9
  6. gac/commit_executor.py +59 -0
  7. gac/config.py +81 -2
  8. gac/config_cli.py +19 -7
  9. gac/constants/__init__.py +34 -0
  10. gac/constants/commit.py +63 -0
  11. gac/constants/defaults.py +40 -0
  12. gac/constants/file_patterns.py +110 -0
  13. gac/constants/languages.py +119 -0
  14. gac/diff_cli.py +0 -22
  15. gac/errors.py +8 -2
  16. gac/git.py +6 -6
  17. gac/git_state_validator.py +193 -0
  18. gac/grouped_commit_workflow.py +458 -0
  19. gac/init_cli.py +2 -1
  20. gac/interactive_mode.py +179 -0
  21. gac/language_cli.py +0 -1
  22. gac/main.py +231 -926
  23. gac/model_cli.py +67 -11
  24. gac/model_identifier.py +70 -0
  25. gac/oauth/__init__.py +26 -0
  26. gac/oauth/claude_code.py +89 -22
  27. gac/oauth/qwen_oauth.py +327 -0
  28. gac/oauth/token_store.py +81 -0
  29. gac/oauth_retry.py +161 -0
  30. gac/postprocess.py +155 -0
  31. gac/prompt.py +21 -479
  32. gac/prompt_builder.py +88 -0
  33. gac/providers/README.md +437 -0
  34. gac/providers/__init__.py +70 -78
  35. gac/providers/anthropic.py +12 -46
  36. gac/providers/azure_openai.py +48 -88
  37. gac/providers/base.py +329 -0
  38. gac/providers/cerebras.py +10 -33
  39. gac/providers/chutes.py +16 -62
  40. gac/providers/claude_code.py +64 -87
  41. gac/providers/custom_anthropic.py +51 -81
  42. gac/providers/custom_openai.py +29 -83
  43. gac/providers/deepseek.py +10 -33
  44. gac/providers/error_handler.py +139 -0
  45. gac/providers/fireworks.py +10 -33
  46. gac/providers/gemini.py +66 -63
  47. gac/providers/groq.py +10 -58
  48. gac/providers/kimi_coding.py +19 -55
  49. gac/providers/lmstudio.py +64 -43
  50. gac/providers/minimax.py +10 -33
  51. gac/providers/mistral.py +10 -33
  52. gac/providers/moonshot.py +10 -33
  53. gac/providers/ollama.py +56 -33
  54. gac/providers/openai.py +30 -36
  55. gac/providers/openrouter.py +15 -52
  56. gac/providers/protocol.py +71 -0
  57. gac/providers/qwen.py +64 -0
  58. gac/providers/registry.py +58 -0
  59. gac/providers/replicate.py +140 -82
  60. gac/providers/streamlake.py +26 -46
  61. gac/providers/synthetic.py +35 -37
  62. gac/providers/together.py +10 -33
  63. gac/providers/zai.py +29 -57
  64. gac/py.typed +0 -0
  65. gac/security.py +1 -1
  66. gac/templates/__init__.py +1 -0
  67. gac/templates/question_generation.txt +60 -0
  68. gac/templates/system_prompt.txt +224 -0
  69. gac/templates/user_prompt.txt +28 -0
  70. gac/utils.py +36 -6
  71. gac/workflow_context.py +162 -0
  72. gac/workflow_utils.py +3 -8
  73. {gac-3.6.0.dist-info → gac-3.10.10.dist-info}/METADATA +6 -4
  74. gac-3.10.10.dist-info/RECORD +79 -0
  75. gac/constants.py +0 -321
  76. gac-3.6.0.dist-info/RECORD +0 -53
  77. {gac-3.6.0.dist-info → gac-3.10.10.dist-info}/WHEEL +0 -0
  78. {gac-3.6.0.dist-info → gac-3.10.10.dist-info}/entry_points.txt +0 -0
  79. {gac-3.6.0.dist-info → gac-3.10.10.dist-info}/licenses/LICENSE +0 -0
gac/model_cli.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import os
4
4
  from pathlib import Path
5
+ from typing import cast
5
6
 
6
7
  import click
7
8
  import questionary
@@ -61,7 +62,7 @@ def _prompt_required_text(prompt: str) -> str | None:
61
62
  return None
62
63
  value = response.strip()
63
64
  if value:
64
- return value # type: ignore[no-any-return]
65
+ return cast(str, value)
65
66
  click.echo("A value is required. Please try again.")
66
67
 
67
68
 
@@ -84,7 +85,7 @@ def _configure_model(existing_env: dict[str, str]) -> bool:
84
85
  ("Azure OpenAI", "gpt-5-mini"),
85
86
  ("Cerebras", "zai-glm-4.6"),
86
87
  ("Chutes", "zai-org/GLM-4.6-FP8"),
87
- ("Claude Code", "claude-sonnet-4-5"),
88
+ ("Claude Code (OAuth)", "claude-sonnet-4-5"),
88
89
  ("Custom (Anthropic)", ""),
89
90
  ("Custom (OpenAI)", ""),
90
91
  ("DeepSeek", "deepseek-chat"),
@@ -99,6 +100,7 @@ def _configure_model(existing_env: dict[str, str]) -> bool:
99
100
  ("Ollama", "gemma3"),
100
101
  ("OpenAI", "gpt-5-mini"),
101
102
  ("OpenRouter", "openrouter/auto"),
103
+ ("Qwen.ai (OAuth)", "qwen3-coder-plus"),
102
104
  ("Replicate", "openai/gpt-oss-120b"),
103
105
  ("Streamlake", ""),
104
106
  ("Synthetic.new", "hf:zai-org/GLM-4.6"),
@@ -116,22 +118,27 @@ def _configure_model(existing_env: dict[str, str]) -> bool:
116
118
  provider_key = provider.lower().replace(".", "").replace(" ", "-").replace("(", "").replace(")", "")
117
119
 
118
120
  is_azure_openai = provider_key == "azure-openai"
119
- is_claude_code = provider_key == "claude-code"
121
+ is_claude_code = provider_key == "claude-code-oauth"
120
122
  is_custom_anthropic = provider_key == "custom-anthropic"
121
123
  is_custom_openai = provider_key == "custom-openai"
122
124
  is_lmstudio = provider_key == "lm-studio"
123
125
  is_ollama = provider_key == "ollama"
126
+ is_qwen = provider_key == "qwenai-oauth"
124
127
  is_streamlake = provider_key == "streamlake"
125
128
  is_zai = provider_key in ("zai", "zai-coding")
126
129
 
127
- if provider_key == "minimaxio":
130
+ if provider_key == "claude-code-oauth":
131
+ provider_key = "claude-code"
132
+ elif provider_key == "kimi-for-coding":
133
+ provider_key = "kimi-coding"
134
+ elif provider_key == "minimaxio":
128
135
  provider_key = "minimax"
129
- elif provider_key == "syntheticnew":
130
- provider_key = "synthetic"
131
136
  elif provider_key == "moonshot-ai":
132
137
  provider_key = "moonshot"
133
- elif provider_key == "kimi-for-coding":
134
- provider_key = "kimi-coding"
138
+ elif provider_key == "qwenai-oauth":
139
+ provider_key = "qwen"
140
+ elif provider_key == "syntheticnew":
141
+ provider_key = "synthetic"
135
142
 
136
143
  if is_streamlake:
137
144
  endpoint_id = _prompt_required_text("Enter the Streamlake inference endpoint ID (required):")
@@ -269,10 +276,12 @@ def _configure_model(existing_env: dict[str, str]) -> bool:
269
276
 
270
277
  # Handle Claude Code OAuth separately
271
278
  if is_claude_code:
272
- from gac.oauth.claude_code import authenticate_and_save, load_stored_token
279
+ from gac.oauth.claude_code import authenticate_and_save
280
+ from gac.oauth.token_store import TokenStore
273
281
 
274
- existing_token = load_stored_token()
275
- if existing_token:
282
+ token_store = TokenStore()
283
+ existing_token_data = token_store.get_token("claude-code")
284
+ if existing_token_data:
276
285
  click.echo("\n✓ Claude Code access token already configured.")
277
286
  action = questionary.select(
278
287
  "What would you like to do?",
@@ -305,6 +314,53 @@ def _configure_model(existing_env: dict[str, str]) -> bool:
305
314
  return False
306
315
  return True
307
316
 
317
+ # Handle Qwen OAuth separately
318
+ if is_qwen:
319
+ from gac.oauth import QwenOAuthProvider, TokenStore
320
+
321
+ token_store = TokenStore()
322
+ qwen_token = token_store.get_token("qwen")
323
+ if qwen_token:
324
+ click.echo("\n✓ Qwen access token already configured.")
325
+ action = questionary.select(
326
+ "What would you like to do?",
327
+ choices=[
328
+ "Keep existing token",
329
+ "Re-authenticate (get new token)",
330
+ ],
331
+ use_shortcuts=True,
332
+ use_arrow_keys=True,
333
+ use_jk_keys=False,
334
+ ).ask()
335
+
336
+ if action is None or action.startswith("Keep existing"):
337
+ if action is None:
338
+ click.echo("Qwen configuration cancelled. Keeping existing token.")
339
+ else:
340
+ click.echo("Keeping existing Qwen token")
341
+ return True
342
+ else:
343
+ click.echo("\n🔐 Starting Qwen OAuth authentication...")
344
+ provider = QwenOAuthProvider(token_store)
345
+ try:
346
+ provider.initiate_auth(open_browser=True)
347
+ click.echo("✅ Qwen authentication completed successfully!")
348
+ return True
349
+ except Exception as e:
350
+ click.echo(f"❌ Qwen authentication failed: {e}")
351
+ return False
352
+ else:
353
+ click.echo("\n🔐 Starting Qwen OAuth authentication...")
354
+ click.echo(" (Your browser will open automatically)\n")
355
+ provider = QwenOAuthProvider(token_store)
356
+ try:
357
+ provider.initiate_auth(open_browser=True)
358
+ click.echo("\n✅ Qwen authentication completed successfully!")
359
+ return True
360
+ except Exception as e:
361
+ click.echo(f"\n❌ Qwen authentication failed: {e}")
362
+ return False
363
+
308
364
  # Determine API key name based on provider
309
365
  if is_lmstudio:
310
366
  api_key_name = "LMSTUDIO_API_KEY"
@@ -0,0 +1,70 @@
1
+ """Model identifier value object for parsing and validating model strings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from gac.errors import ConfigError
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ModelIdentifier:
12
+ """Represents a parsed model identifier in the format 'provider:model_name'.
13
+
14
+ This is an immutable value object that ensures model identifiers are
15
+ properly validated and provides convenient access to the components.
16
+
17
+ Attributes:
18
+ provider: The provider name (e.g., 'openai', 'anthropic', 'claude-code')
19
+ model_name: The model name (e.g., 'gpt-4o-mini', 'claude-haiku-4-5')
20
+ """
21
+
22
+ provider: str
23
+ model_name: str
24
+
25
+ @classmethod
26
+ def parse(cls, model_string: str) -> ModelIdentifier:
27
+ """Parse a model string into a ModelIdentifier.
28
+
29
+ Args:
30
+ model_string: A string in the format 'provider:model_name'
31
+
32
+ Returns:
33
+ A ModelIdentifier instance
34
+
35
+ Raises:
36
+ ConfigError: If the format is invalid or components are empty
37
+ """
38
+ normalized = model_string.strip()
39
+
40
+ if ":" not in normalized:
41
+ raise ConfigError(
42
+ f"Invalid model format: '{model_string}'. Expected 'provider:model', "
43
+ "e.g. 'openai:gpt-4o-mini'. Use 'gac config set model <provider:model>' "
44
+ "to update your configuration."
45
+ )
46
+
47
+ provider, model_name = normalized.split(":", 1)
48
+
49
+ if not provider or not model_name:
50
+ raise ConfigError(
51
+ f"Invalid model format: '{model_string}'. Both provider and model name "
52
+ "are required (example: 'anthropic:claude-haiku-4-5')."
53
+ )
54
+
55
+ return cls(provider=provider, model_name=model_name)
56
+
57
+ def __str__(self) -> str:
58
+ """Return the canonical string representation."""
59
+ return f"{self.provider}:{self.model_name}"
60
+
61
+ def starts_with_provider(self, prefix: str) -> bool:
62
+ """Check if the provider starts with the given prefix.
63
+
64
+ Args:
65
+ prefix: The prefix to check (e.g., 'claude-code', 'qwen')
66
+
67
+ Returns:
68
+ True if the provider matches or the full identifier starts with prefix
69
+ """
70
+ return self.provider == prefix or str(self).startswith(f"{prefix}:")
gac/oauth/__init__.py CHANGED
@@ -1 +1,27 @@
1
1
  """OAuth authentication utilities for GAC."""
2
+
3
+ from .claude_code import (
4
+ authenticate_and_save,
5
+ is_token_expired,
6
+ load_stored_token,
7
+ perform_oauth_flow,
8
+ refresh_token_if_expired,
9
+ remove_token,
10
+ save_token,
11
+ )
12
+ from .qwen_oauth import QwenDeviceFlow, QwenOAuthProvider
13
+ from .token_store import OAuthToken, TokenStore
14
+
15
+ __all__ = [
16
+ "authenticate_and_save",
17
+ "is_token_expired",
18
+ "load_stored_token",
19
+ "OAuthToken",
20
+ "perform_oauth_flow",
21
+ "QwenDeviceFlow",
22
+ "QwenOAuthProvider",
23
+ "refresh_token_if_expired",
24
+ "remove_token",
25
+ "save_token",
26
+ "TokenStore",
27
+ ]
gac/oauth/claude_code.py CHANGED
@@ -12,12 +12,14 @@ import time
12
12
  import webbrowser
13
13
  from dataclasses import dataclass
14
14
  from http.server import BaseHTTPRequestHandler, HTTPServer
15
- from pathlib import Path
16
- from typing import Any, TypedDict
15
+ from typing import Any, TypedDict, cast
17
16
  from urllib.parse import parse_qs, urlencode, urlparse
18
17
 
19
18
  import httpx
20
19
 
20
+ from gac.oauth.token_store import OAuthToken, TokenStore
21
+ from gac.utils import get_ssl_verify
22
+
21
23
  logger = logging.getLogger(__name__)
22
24
 
23
25
 
@@ -250,6 +252,7 @@ def exchange_code_for_tokens(auth_code: str, context: OAuthContext) -> dict[str,
250
252
  json=payload,
251
253
  headers=headers,
252
254
  timeout=30,
255
+ verify=get_ssl_verify(),
253
256
  )
254
257
  logger.info("Token exchange response: %s", response.status_code)
255
258
  if response.status_code == 200:
@@ -328,7 +331,7 @@ def perform_oauth_flow(quiet: bool = False) -> dict[str, Any] | None:
328
331
  print("✓ Authorization code received")
329
332
  print(" Exchanging for access token...\n")
330
333
 
331
- tokens = exchange_code_for_tokens(result.code, context) # type: ignore[arg-type]
334
+ tokens = exchange_code_for_tokens(cast(str, result.code), context)
332
335
  if not tokens:
333
336
  if not quiet:
334
337
  print("❌ Token exchange failed. Please try again.")
@@ -340,32 +343,82 @@ def perform_oauth_flow(quiet: bool = False) -> dict[str, Any] | None:
340
343
  return tokens
341
344
 
342
345
 
343
- def get_token_storage_path() -> Path:
344
- """Get path for storing OAuth tokens."""
345
- return Path.home() / ".gac.env"
346
+ def load_stored_token() -> str | None:
347
+ """Load stored access token from token store."""
348
+ store = TokenStore()
349
+ token = store.get_token("claude-code")
350
+ if token:
351
+ return token.get("access_token")
352
+ return None
346
353
 
347
354
 
348
- def load_stored_token() -> str | None:
349
- """Load stored access token from .gac.env."""
350
- from dotenv import dotenv_values
355
+ def is_token_expired() -> bool:
356
+ """Check if the stored Claude Code token has expired.
351
357
 
352
- env_path = get_token_storage_path()
353
- if not env_path.exists():
354
- return None
358
+ Returns True if the token is expired or close to expiring (within 5 minutes).
359
+ """
360
+ store = TokenStore()
361
+ token = store.get_token("claude-code")
362
+ if not token:
363
+ return True
364
+
365
+ expiry = token.get("expiry")
366
+ if not expiry:
367
+ # No expiry information, assume it's still valid
368
+ return False
355
369
 
356
- env_vars = dotenv_values(str(env_path))
357
- return env_vars.get("CLAUDE_CODE_ACCESS_TOKEN")
370
+ # Consider token expired if it expires within 5 minutes
371
+ current_time = time.time()
372
+ return current_time >= (expiry - 300)
358
373
 
359
374
 
360
- def save_token(access_token: str) -> bool:
361
- """Save access token to .gac.env and update environment."""
362
- import os
375
+ def refresh_token_if_expired(quiet: bool = True) -> bool:
376
+ """Refresh the Claude Code token if it has expired.
377
+
378
+ Args:
379
+ quiet: If True, suppress output messages
380
+
381
+ Returns:
382
+ True if token is valid (or was successfully refreshed), False otherwise
383
+ """
384
+ if not is_token_expired():
385
+ return True
386
+
387
+ if not quiet:
388
+ logger.info("Claude Code token expired, attempting to refresh...")
363
389
 
364
- from dotenv import set_key
390
+ # Perform OAuth flow to get a new token
391
+ success = authenticate_and_save(quiet=quiet)
392
+ if not success and not quiet:
393
+ logger.error("Failed to refresh Claude Code token")
365
394
 
366
- env_path = get_token_storage_path()
395
+ return success
396
+
397
+
398
+ def save_token(access_token: str, token_data: dict[str, Any] | None = None) -> bool:
399
+ """Save access token to token store.
400
+
401
+ Args:
402
+ access_token: The OAuth access token string
403
+ token_data: Optional full token response data (includes expiry info)
404
+ """
405
+ import os
406
+
407
+ store = TokenStore()
367
408
  try:
368
- set_key(str(env_path), "CLAUDE_CODE_ACCESS_TOKEN", access_token)
409
+ token: OAuthToken = {
410
+ "access_token": access_token,
411
+ "token_type": "Bearer",
412
+ }
413
+
414
+ # Add expiry information if available
415
+ if token_data:
416
+ if "expires_at" in token_data:
417
+ token["expiry"] = int(token_data["expires_at"])
418
+ elif "expires_in" in token_data:
419
+ token["expiry"] = int(time.time() + token_data["expires_in"])
420
+
421
+ store.save_token("claude-code", token)
369
422
  # Also update the current environment so the token is immediately available
370
423
  os.environ["CLAUDE_CODE_ACCESS_TOKEN"] = access_token
371
424
  return True
@@ -374,6 +427,20 @@ def save_token(access_token: str) -> bool:
374
427
  return False
375
428
 
376
429
 
430
+ def remove_token() -> bool:
431
+ """Remove stored access token from token store."""
432
+ import os
433
+
434
+ store = TokenStore()
435
+ try:
436
+ store.remove_token("claude-code")
437
+ os.environ.pop("CLAUDE_CODE_ACCESS_TOKEN", None)
438
+ return True
439
+ except Exception as exc:
440
+ logger.error("Failed to remove token: %s", exc)
441
+ return False
442
+
443
+
377
444
  def authenticate_and_save(quiet: bool = False) -> bool:
378
445
  """Perform OAuth flow and save token."""
379
446
  tokens = perform_oauth_flow(quiet=quiet)
@@ -386,12 +453,12 @@ def authenticate_and_save(quiet: bool = False) -> bool:
386
453
  print("❌ No access token returned from authentication")
387
454
  return False
388
455
 
389
- if not save_token(access_token):
456
+ if not save_token(access_token, token_data=tokens):
390
457
  if not quiet:
391
458
  print("❌ Failed to save access token")
392
459
  return False
393
460
 
394
461
  if not quiet:
395
- print(f"✓ Access token saved to {get_token_storage_path()}")
462
+ print("✓ Access token saved to ~/.gac/oauth/claude-code.json")
396
463
 
397
464
  return True