gac 2.2.0__tar.gz → 2.3.0__tar.gz

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 (46) hide show
  1. {gac-2.2.0 → gac-2.3.0}/PKG-INFO +5 -1
  2. {gac-2.2.0 → gac-2.3.0}/README.md +4 -0
  3. {gac-2.2.0 → gac-2.3.0}/src/gac/__version__.py +1 -1
  4. {gac-2.2.0 → gac-2.3.0}/src/gac/ai.py +26 -0
  5. {gac-2.2.0 → gac-2.3.0}/src/gac/ai_utils.py +28 -13
  6. {gac-2.2.0 → gac-2.3.0}/src/gac/cli.py +4 -0
  7. {gac-2.2.0 → gac-2.3.0}/src/gac/constants.py +1 -1
  8. {gac-2.2.0 → gac-2.3.0}/src/gac/git.py +42 -0
  9. gac-2.3.0/src/gac/init_cli.py +316 -0
  10. {gac-2.2.0 → gac-2.3.0}/src/gac/language_cli.py +8 -8
  11. gac-2.3.0/src/gac/main.py +718 -0
  12. {gac-2.2.0 → gac-2.3.0}/src/gac/prompt.py +101 -15
  13. {gac-2.2.0 → gac-2.3.0}/src/gac/security.py +1 -1
  14. gac-2.3.0/src/gac/workflow_utils.py +131 -0
  15. gac-2.2.0/src/gac/init_cli.py +0 -203
  16. gac-2.2.0/src/gac/main.py +0 -382
  17. {gac-2.2.0 → gac-2.3.0}/.gitignore +0 -0
  18. {gac-2.2.0 → gac-2.3.0}/LICENSE +0 -0
  19. {gac-2.2.0 → gac-2.3.0}/pyproject.toml +0 -0
  20. {gac-2.2.0 → gac-2.3.0}/src/gac/__init__.py +0 -0
  21. {gac-2.2.0 → gac-2.3.0}/src/gac/config.py +0 -0
  22. {gac-2.2.0 → gac-2.3.0}/src/gac/config_cli.py +0 -0
  23. {gac-2.2.0 → gac-2.3.0}/src/gac/diff_cli.py +0 -0
  24. {gac-2.2.0 → gac-2.3.0}/src/gac/errors.py +0 -0
  25. {gac-2.2.0 → gac-2.3.0}/src/gac/preprocess.py +0 -0
  26. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/__init__.py +0 -0
  27. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/anthropic.py +0 -0
  28. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/cerebras.py +0 -0
  29. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/chutes.py +0 -0
  30. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/custom_anthropic.py +0 -0
  31. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/custom_openai.py +0 -0
  32. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/deepseek.py +0 -0
  33. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/fireworks.py +0 -0
  34. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/gemini.py +0 -0
  35. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/groq.py +0 -0
  36. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/lmstudio.py +0 -0
  37. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/minimax.py +0 -0
  38. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/mistral.py +0 -0
  39. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/ollama.py +0 -0
  40. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/openai.py +0 -0
  41. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/openrouter.py +0 -0
  42. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/streamlake.py +0 -0
  43. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/synthetic.py +0 -0
  44. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/together.py +0 -0
  45. {gac-2.2.0 → gac-2.3.0}/src/gac/providers/zai.py +0 -0
  46. {gac-2.2.0 → gac-2.3.0}/src/gac/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gac
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: LLM-powered Git commit message generator with multi-provider support
5
5
  Project-URL: Homepage, https://github.com/cellwebb/gac
6
6
  Project-URL: Documentation, https://github.com/cellwebb/gac#readme
@@ -108,6 +108,7 @@ uv tool upgrade gac
108
108
  - **Understands intent**: Analyzes code structure, logic, and patterns to understand the "why" behind your changes, not just what changed
109
109
  - **Semantic awareness**: Recognizes refactoring, bug fixes, features, and breaking changes to generate contextually appropriate messages
110
110
  - **Intelligent filtering**: Prioritizes meaningful changes while ignoring generated files, dependencies, and artifacts
111
+ - **Intelligent commit grouping** - Automatically group related changes into multiple logical commits with `--group`
111
112
 
112
113
  ### 📝 **Multiple Message Formats**
113
114
 
@@ -175,6 +176,9 @@ gac -v -s
175
176
  # Quick one-liner for small changes
176
177
  gac -o
177
178
 
179
+ # Group changes into logically related commits
180
+ gac -ag
181
+
178
182
  # Debug what the LLM sees
179
183
  gac --show-prompt
180
184
 
@@ -66,6 +66,7 @@ uv tool upgrade gac
66
66
  - **Understands intent**: Analyzes code structure, logic, and patterns to understand the "why" behind your changes, not just what changed
67
67
  - **Semantic awareness**: Recognizes refactoring, bug fixes, features, and breaking changes to generate contextually appropriate messages
68
68
  - **Intelligent filtering**: Prioritizes meaningful changes while ignoring generated files, dependencies, and artifacts
69
+ - **Intelligent commit grouping** - Automatically group related changes into multiple logical commits with `--group`
69
70
 
70
71
  ### 📝 **Multiple Message Formats**
71
72
 
@@ -133,6 +134,9 @@ gac -v -s
133
134
  # Quick one-liner for small changes
134
135
  gac -o
135
136
 
137
+ # Group changes into logically related commits
138
+ gac -ag
139
+
136
140
  # Debug what the LLM sees
137
141
  gac --show-prompt
138
142
 
@@ -1,3 +1,3 @@
1
1
  """Version information for gac package."""
2
2
 
3
- __version__ = "2.2.0"
3
+ __version__ = "2.3.0"
@@ -42,6 +42,8 @@ def generate_commit_message(
42
42
  max_tokens: int = EnvDefaults.MAX_OUTPUT_TOKENS,
43
43
  max_retries: int = EnvDefaults.MAX_RETRIES,
44
44
  quiet: bool = False,
45
+ is_group: bool = False,
46
+ skip_success_message: bool = False,
45
47
  ) -> str:
46
48
  """Generate a commit message using direct API calls to AI providers.
47
49
 
@@ -116,6 +118,8 @@ def generate_commit_message(
116
118
  max_tokens=max_tokens,
117
119
  max_retries=max_retries,
118
120
  quiet=quiet,
121
+ is_group=is_group,
122
+ skip_success_message=skip_success_message,
119
123
  )
120
124
  except AIError:
121
125
  # Re-raise AIError exceptions as-is to preserve error classification
@@ -123,3 +127,25 @@ def generate_commit_message(
123
127
  except Exception as e:
124
128
  logger.error(f"Failed to generate commit message: {e}")
125
129
  raise AIError.model_error(f"Failed to generate commit message: {e}") from e
130
+
131
+
132
+ def generate_grouped_commits(
133
+ model: str,
134
+ prompt: list[dict[str, str]],
135
+ temperature: float,
136
+ max_tokens: int,
137
+ max_retries: int,
138
+ quiet: bool = False,
139
+ skip_success_message: bool = False,
140
+ ) -> str:
141
+ """Generate grouped commits JSON response."""
142
+ return generate_commit_message(
143
+ model=model,
144
+ prompt=prompt,
145
+ temperature=temperature,
146
+ max_tokens=max_tokens,
147
+ max_retries=max_retries,
148
+ quiet=quiet,
149
+ is_group=True,
150
+ skip_success_message=skip_success_message,
151
+ )
@@ -83,6 +83,8 @@ def generate_with_retries(
83
83
  max_tokens: int,
84
84
  max_retries: int,
85
85
  quiet: bool = False,
86
+ is_group: bool = False,
87
+ skip_success_message: bool = False,
86
88
  ) -> str:
87
89
  """Generate content with retry logic using direct API calls."""
88
90
  # Parse model string to determine provider and actual model
@@ -121,10 +123,11 @@ def generate_with_retries(
121
123
  raise AIError.model_error("No messages provided for AI generation")
122
124
 
123
125
  # Set up spinner
126
+ message_type = "commit messages" if is_group else "commit message"
124
127
  if quiet:
125
128
  spinner = None
126
129
  else:
127
- spinner = Halo(text=f"Generating commit message with {provider} {model_name}...", spinner="dots")
130
+ spinner = Halo(text=f"Generating {message_type} with {provider} {model_name}...", spinner="dots")
128
131
  spinner.start()
129
132
 
130
133
  last_exception = None
@@ -132,7 +135,7 @@ def generate_with_retries(
132
135
 
133
136
  for attempt in range(max_retries):
134
137
  try:
135
- if not quiet and attempt > 0:
138
+ if not quiet and not skip_success_message and attempt > 0:
136
139
  if spinner:
137
140
  spinner.text = f"Retry {attempt + 1}/{max_retries} with {provider} {model_name}..."
138
141
  logger.info(f"Retry attempt {attempt + 1}/{max_retries}")
@@ -145,7 +148,10 @@ def generate_with_retries(
145
148
  content = provider_func(model=model_name, messages=messages, temperature=temperature, max_tokens=max_tokens)
146
149
 
147
150
  if spinner:
148
- spinner.succeed(f"Generated commit message with {provider} {model_name}")
151
+ if skip_success_message:
152
+ spinner.stop() # Stop spinner without showing success/failure
153
+ else:
154
+ spinner.succeed(f"Generated {message_type} with {provider} {model_name}")
149
155
 
150
156
  if content is not None and content.strip():
151
157
  return content.strip() # type: ignore[no-any-return]
@@ -160,8 +166,8 @@ def generate_with_retries(
160
166
 
161
167
  # For authentication and model errors, don't retry
162
168
  if error_type in ["authentication", "model"]:
163
- if spinner:
164
- spinner.fail(f"Failed to generate commit message with {provider} {model_name}")
169
+ if spinner and not skip_success_message:
170
+ spinner.fail(f"Failed to generate {message_type} with {provider} {model_name}")
165
171
 
166
172
  # Create the appropriate error type based on classification
167
173
  if error_type == "authentication":
@@ -172,23 +178,32 @@ def generate_with_retries(
172
178
  if attempt < max_retries - 1:
173
179
  # Exponential backoff
174
180
  wait_time = 2**attempt
175
- if not quiet:
176
- logger.warning(f"AI generation failed (attempt {attempt + 1}), retrying in {wait_time}s: {str(e)}")
177
-
178
- if spinner:
181
+ if not quiet and not skip_success_message:
182
+ if attempt == 0:
183
+ logger.warning(f"AI generation failed, retrying in {wait_time}s: {str(e)}")
184
+ else:
185
+ logger.warning(
186
+ f"AI generation failed (attempt {attempt + 1}), retrying in {wait_time}s: {str(e)}"
187
+ )
188
+
189
+ if spinner and not skip_success_message:
179
190
  for i in range(wait_time, 0, -1):
180
191
  spinner.text = f"Retry {attempt + 1}/{max_retries} in {i}s..."
181
192
  time.sleep(1)
182
193
  else:
183
194
  time.sleep(wait_time)
184
195
  else:
185
- logger.error(f"AI generation failed after {max_retries} attempts: {str(e)}")
196
+ num_retries = max_retries
197
+ retry_word = "retry" if num_retries == 1 else "retries"
198
+ logger.error(f"AI generation failed after {num_retries} {retry_word}: {str(e)}")
186
199
 
187
- if spinner:
188
- spinner.fail(f"Failed to generate commit message with {provider} {model_name}")
200
+ if spinner and not skip_success_message:
201
+ spinner.fail(f"Failed to generate {message_type} with {provider} {model_name}")
189
202
 
190
203
  # If we get here, all retries failed - use the last classified error type
191
- error_message = f"Failed to generate commit message after {max_retries} attempts"
204
+ num_retries = max_retries
205
+ retry_word = "retry" if num_retries == 1 else "retries"
206
+ error_message = f"Failed to generate {message_type} after {num_retries} {retry_word}"
192
207
  if last_error_type == "authentication":
193
208
  raise AIError.authentication_error(error_message) from last_exception
194
209
  elif last_error_type == "rate_limit":
@@ -28,6 +28,7 @@ logger = logging.getLogger(__name__)
28
28
  @click.group(invoke_without_command=True, context_settings={"ignore_unknown_options": True})
29
29
  # Git workflow options
30
30
  @click.option("--add-all", "-a", is_flag=True, help="Stage all changes before committing")
31
+ @click.option("--group", "-g", is_flag=True, help="Group changes into multiple logical commits")
31
32
  @click.option("--push", "-p", is_flag=True, help="Push changes to remote after committing")
32
33
  @click.option("--dry-run", is_flag=True, help="Dry run the commit workflow")
33
34
  @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
@@ -70,6 +71,7 @@ logger = logging.getLogger(__name__)
70
71
  def cli(
71
72
  ctx: click.Context,
72
73
  add_all: bool = False,
74
+ group: bool = False,
73
75
  log_level: str = str(config["log_level"]),
74
76
  one_liner: bool = False,
75
77
  push: bool = False,
@@ -109,6 +111,7 @@ def cli(
109
111
  try:
110
112
  main(
111
113
  stage_all=add_all,
114
+ group=group,
112
115
  model=model,
113
116
  hint=hint,
114
117
  one_liner=one_liner,
@@ -131,6 +134,7 @@ def cli(
131
134
 
132
135
  ctx.obj = {
133
136
  "add_all": add_all,
137
+ "group": group,
134
138
  "log_level": log_level,
135
139
  "one_liner": one_liner,
136
140
  "push": push,
@@ -20,7 +20,7 @@ class EnvDefaults:
20
20
 
21
21
  MAX_RETRIES: int = 3
22
22
  TEMPERATURE: float = 1
23
- MAX_OUTPUT_TOKENS: int = 1024 # includes reasoning tokens
23
+ MAX_OUTPUT_TOKENS: int = 4096 # includes reasoning tokens
24
24
  WARNING_LIMIT_TOKENS: int = 32768
25
25
  ALWAYS_INCLUDE_SCOPE: bool = False
26
26
  SKIP_SECRET_SCAN: bool = False
@@ -50,6 +50,48 @@ def get_staged_files(file_type: str | None = None, existing_only: bool = False)
50
50
  return []
51
51
 
52
52
 
53
+ def get_staged_status() -> str:
54
+ """Get formatted status of staged files only, excluding unstaged/untracked files.
55
+
56
+ Returns:
57
+ Formatted status string with M/A/D/R indicators
58
+ """
59
+ try:
60
+ output = run_git_command(["diff", "--name-status", "--staged"])
61
+ if not output:
62
+ return "No changes staged for commit."
63
+
64
+ status_map = {
65
+ "M": "modified",
66
+ "A": "new file",
67
+ "D": "deleted",
68
+ "R": "renamed",
69
+ "C": "copied",
70
+ "T": "typechange",
71
+ }
72
+
73
+ status_lines = ["Changes to be committed:"]
74
+ for line in output.splitlines():
75
+ line = line.strip()
76
+ if not line:
77
+ continue
78
+
79
+ # Parse status line (e.g., "M\tfile.py" or "R100\told.py\tnew.py")
80
+ parts = line.split("\t")
81
+ if len(parts) < 2:
82
+ continue
83
+
84
+ change_type = parts[0][0] # First char is the status (M, A, D, R, etc.)
85
+ file_path = parts[-1] # Last part is the new/current file path
86
+
87
+ status_label = status_map.get(change_type, "modified")
88
+ status_lines.append(f"\t{status_label}: {file_path}")
89
+
90
+ return "\n".join(status_lines)
91
+ except GitError:
92
+ return "No changes staged for commit."
93
+
94
+
53
95
  def get_diff(staged: bool = True, color: bool = True, commit1: str | None = None, commit2: str | None = None) -> str:
54
96
  """Get the diff between commits or working tree.
55
97
 
@@ -0,0 +1,316 @@
1
+ """CLI for initializing gac configuration interactively."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ import questionary
7
+ from dotenv import dotenv_values, set_key
8
+
9
+ from gac.constants import Languages
10
+
11
+ GAC_ENV_PATH = Path.home() / ".gac.env"
12
+
13
+
14
+ def _prompt_required_text(prompt: str) -> str | None:
15
+ """Prompt until a non-empty string is provided or the user cancels."""
16
+ while True:
17
+ response = questionary.text(prompt).ask()
18
+ if response is None:
19
+ return None
20
+ value = response.strip()
21
+ if value:
22
+ return value # type: ignore[no-any-return]
23
+ click.echo("A value is required. Please try again.")
24
+
25
+
26
+ @click.command()
27
+ def init() -> None:
28
+ """Interactively set up $HOME/.gac.env for gac."""
29
+ click.echo("Welcome to gac initialization!\n")
30
+
31
+ # Load existing environment values
32
+ existing_env = {}
33
+ if GAC_ENV_PATH.exists():
34
+ click.echo(f"$HOME/.gac.env already exists at {GAC_ENV_PATH}. Values will be updated.")
35
+ existing_env = dict(dotenv_values(str(GAC_ENV_PATH)))
36
+ else:
37
+ GAC_ENV_PATH.touch()
38
+ click.echo(f"Created $HOME/.gac.env at {GAC_ENV_PATH}.")
39
+
40
+ providers = [
41
+ ("Anthropic", "claude-haiku-4-5"),
42
+ ("Cerebras", "zai-glm-4.6"),
43
+ ("Chutes", "zai-org/GLM-4.6-FP8"),
44
+ ("Custom (Anthropic)", ""),
45
+ ("Custom (OpenAI)", ""),
46
+ ("DeepSeek", "deepseek-chat"),
47
+ ("Fireworks", "accounts/fireworks/models/gpt-oss-20b"),
48
+ ("Gemini", "gemini-2.5-flash"),
49
+ ("Groq", "meta-llama/llama-4-maverick-17b-128e-instruct"),
50
+ ("LM Studio", "gemma3"),
51
+ ("MiniMax", "MiniMax-M2"),
52
+ ("Mistral", "mistral-small-latest"),
53
+ ("Ollama", "gemma3"),
54
+ ("OpenAI", "gpt-4.1-mini"),
55
+ ("OpenRouter", "openrouter/auto"),
56
+ ("Streamlake", ""),
57
+ ("Synthetic", "hf:zai-org/GLM-4.6"),
58
+ ("Together AI", "openai/gpt-oss-20B"),
59
+ ("Z.AI", "glm-4.5-air"),
60
+ ("Z.AI Coding", "glm-4.6"),
61
+ ]
62
+ provider_names = [p[0] for p in providers]
63
+ provider = questionary.select("Select your provider:", choices=provider_names).ask()
64
+ if not provider:
65
+ click.echo("Provider selection cancelled. Exiting.")
66
+ return
67
+ provider_key = provider.lower().replace(".", "").replace(" ", "-").replace("(", "").replace(")", "")
68
+
69
+ is_ollama = provider_key == "ollama"
70
+ is_lmstudio = provider_key == "lm-studio"
71
+ is_streamlake = provider_key == "streamlake"
72
+ is_zai = provider_key in ("zai", "zai-coding")
73
+ is_custom_anthropic = provider_key == "custom-anthropic"
74
+ is_custom_openai = provider_key == "custom-openai"
75
+
76
+ if is_streamlake:
77
+ endpoint_id = _prompt_required_text("Enter the Streamlake inference endpoint ID (required):")
78
+ if endpoint_id is None:
79
+ click.echo("Streamlake configuration cancelled. Exiting.")
80
+ return
81
+ model_to_save = endpoint_id
82
+ else:
83
+ model_suggestion = dict(providers)[provider]
84
+ if model_suggestion == "":
85
+ model_prompt = "Enter the model (required):"
86
+ else:
87
+ model_prompt = f"Enter the model (default: {model_suggestion}):"
88
+ model = questionary.text(model_prompt, default=model_suggestion).ask()
89
+ if model is None:
90
+ click.echo("Model entry cancelled. Exiting.")
91
+ return
92
+ model_to_save = model.strip() if model.strip() else model_suggestion
93
+
94
+ set_key(str(GAC_ENV_PATH), "GAC_MODEL", f"{provider_key}:{model_to_save}")
95
+ click.echo(f"Set GAC_MODEL={provider_key}:{model_to_save}")
96
+
97
+ if is_custom_anthropic:
98
+ base_url = _prompt_required_text("Enter the custom Anthropic-compatible base URL (required):")
99
+ if base_url is None:
100
+ click.echo("Custom Anthropic base URL entry cancelled. Exiting.")
101
+ return
102
+ set_key(str(GAC_ENV_PATH), "CUSTOM_ANTHROPIC_BASE_URL", base_url)
103
+ click.echo(f"Set CUSTOM_ANTHROPIC_BASE_URL={base_url}")
104
+
105
+ api_version = questionary.text(
106
+ "Enter the API version (optional, press Enter for default: 2023-06-01):", default="2023-06-01"
107
+ ).ask()
108
+ if api_version and api_version != "2023-06-01":
109
+ set_key(str(GAC_ENV_PATH), "CUSTOM_ANTHROPIC_VERSION", api_version)
110
+ click.echo(f"Set CUSTOM_ANTHROPIC_VERSION={api_version}")
111
+ elif is_custom_openai:
112
+ base_url = _prompt_required_text("Enter the custom OpenAI-compatible base URL (required):")
113
+ if base_url is None:
114
+ click.echo("Custom OpenAI base URL entry cancelled. Exiting.")
115
+ return
116
+ set_key(str(GAC_ENV_PATH), "CUSTOM_OPENAI_BASE_URL", base_url)
117
+ click.echo(f"Set CUSTOM_OPENAI_BASE_URL={base_url}")
118
+ elif is_ollama:
119
+ url_default = "http://localhost:11434"
120
+ url = questionary.text(f"Enter the Ollama API URL (default: {url_default}):", default=url_default).ask()
121
+ if url is None:
122
+ click.echo("Ollama URL entry cancelled. Exiting.")
123
+ return
124
+ url_to_save = url.strip() if url.strip() else url_default
125
+ set_key(str(GAC_ENV_PATH), "OLLAMA_API_URL", url_to_save)
126
+ click.echo(f"Set OLLAMA_API_URL={url_to_save}")
127
+ elif is_lmstudio:
128
+ url_default = "http://localhost:1234"
129
+ url = questionary.text(f"Enter the LM Studio API URL (default: {url_default}):", default=url_default).ask()
130
+ if url is None:
131
+ click.echo("LM Studio URL entry cancelled. Exiting.")
132
+ return
133
+ url_to_save = url.strip() if url.strip() else url_default
134
+ set_key(str(GAC_ENV_PATH), "LMSTUDIO_API_URL", url_to_save)
135
+ click.echo(f"Set LMSTUDIO_API_URL={url_to_save}")
136
+
137
+ # Determine API key name based on provider
138
+ if is_lmstudio:
139
+ api_key_name = "LMSTUDIO_API_KEY"
140
+ elif is_zai:
141
+ api_key_name = "ZAI_API_KEY"
142
+ elif is_custom_anthropic:
143
+ api_key_name = "CUSTOM_ANTHROPIC_API_KEY"
144
+ elif is_custom_openai:
145
+ api_key_name = "CUSTOM_OPENAI_API_KEY"
146
+ else:
147
+ api_key_name = f"{provider_key.upper()}_API_KEY"
148
+
149
+ # Check if API key already exists
150
+ existing_key = existing_env.get(api_key_name)
151
+
152
+ if existing_key:
153
+ # Key exists - offer options
154
+ click.echo(f"\n{api_key_name} is already configured.")
155
+ action = questionary.select(
156
+ "What would you like to do?",
157
+ choices=[
158
+ "Keep existing key",
159
+ "Enter new key",
160
+ ],
161
+ ).ask()
162
+
163
+ if action is None:
164
+ click.echo("API key configuration cancelled. Keeping existing key.")
165
+ elif action.startswith("Keep existing"):
166
+ click.echo(f"Keeping existing {api_key_name}")
167
+ elif action.startswith("Enter new"):
168
+ api_key = questionary.password("Enter your new API key (input hidden):").ask()
169
+ if api_key and api_key.strip():
170
+ set_key(str(GAC_ENV_PATH), api_key_name, api_key)
171
+ click.echo(f"Updated {api_key_name} (hidden)")
172
+ else:
173
+ click.echo(f"No key entered. Keeping existing {api_key_name}")
174
+ else:
175
+ # No existing key - prompt for new one
176
+ api_key_prompt = "Enter your API key (input hidden, can be set later):"
177
+ if is_ollama or is_lmstudio:
178
+ click.echo(
179
+ "This provider typically runs locally. API keys are optional unless your instance requires authentication."
180
+ )
181
+ api_key_prompt = "Enter your API key (optional, press Enter to skip):"
182
+
183
+ api_key = questionary.password(api_key_prompt).ask()
184
+ if api_key and api_key.strip():
185
+ set_key(str(GAC_ENV_PATH), api_key_name, api_key)
186
+ click.echo(f"Set {api_key_name} (hidden)")
187
+ elif is_ollama or is_lmstudio:
188
+ click.echo("Skipping API key. You can add one later if needed.")
189
+ else:
190
+ click.echo("No API key entered. You can add one later by editing ~/.gac.env")
191
+
192
+ # Language selection
193
+ click.echo("\n")
194
+ existing_language = existing_env.get("GAC_LANGUAGE")
195
+
196
+ if existing_language:
197
+ # Language already configured - offer options
198
+ existing_translate = existing_env.get("GAC_TRANSLATE_PREFIXES", "false")
199
+ translate_status = "with translated prefixes" if existing_translate == "true" else "with English prefixes"
200
+ click.echo(f"Language is already configured: {existing_language} ({translate_status})")
201
+
202
+ action = questionary.select(
203
+ "What would you like to do?",
204
+ choices=[
205
+ "Keep existing language",
206
+ "Select new language",
207
+ ],
208
+ ).ask()
209
+
210
+ if action is None or action.startswith("Keep existing"):
211
+ if action is None:
212
+ click.echo("Language configuration cancelled. Keeping existing language.")
213
+ else:
214
+ click.echo(f"Keeping existing language: {existing_language}")
215
+ elif action.startswith("Select new"):
216
+ # Proceed with language selection
217
+ display_names = [lang[0] for lang in Languages.LANGUAGES]
218
+ language_selection = questionary.select(
219
+ "Select a language for commit messages:", choices=display_names, use_shortcuts=True, use_arrow_keys=True
220
+ ).ask()
221
+
222
+ if not language_selection:
223
+ click.echo("Language selection cancelled. Keeping existing language.")
224
+ elif language_selection == "English":
225
+ set_key(str(GAC_ENV_PATH), "GAC_LANGUAGE", "English")
226
+ set_key(str(GAC_ENV_PATH), "GAC_TRANSLATE_PREFIXES", "false")
227
+ click.echo("Set GAC_LANGUAGE=English")
228
+ click.echo("Set GAC_TRANSLATE_PREFIXES=false")
229
+ else:
230
+ # Handle custom input
231
+ if language_selection == "Custom":
232
+ custom_language = questionary.text(
233
+ "Enter the language name (e.g., 'Spanish', 'Français', '日本語'):"
234
+ ).ask()
235
+ if not custom_language or not custom_language.strip():
236
+ click.echo("No language entered. Keeping existing language.")
237
+ language_value = None
238
+ else:
239
+ language_value = custom_language.strip()
240
+ else:
241
+ # Find the English name for the selected language
242
+ language_value = next(lang[1] for lang in Languages.LANGUAGES if lang[0] == language_selection)
243
+
244
+ if language_value:
245
+ # Ask about prefix translation
246
+ prefix_choice = questionary.select(
247
+ "How should conventional commit prefixes be handled?",
248
+ choices=[
249
+ "Keep prefixes in English (feat:, fix:, etc.)",
250
+ f"Translate prefixes into {language_value}",
251
+ ],
252
+ ).ask()
253
+
254
+ if not prefix_choice:
255
+ click.echo("Prefix translation selection cancelled. Using English prefixes.")
256
+ translate_prefixes = False
257
+ else:
258
+ translate_prefixes = prefix_choice.startswith("Translate prefixes")
259
+
260
+ # Set the language and prefix translation preference
261
+ set_key(str(GAC_ENV_PATH), "GAC_LANGUAGE", language_value)
262
+ set_key(str(GAC_ENV_PATH), "GAC_TRANSLATE_PREFIXES", "true" if translate_prefixes else "false")
263
+ click.echo(f"Set GAC_LANGUAGE={language_value}")
264
+ click.echo(f"Set GAC_TRANSLATE_PREFIXES={'true' if translate_prefixes else 'false'}")
265
+ else:
266
+ # No existing language - proceed with normal flow
267
+ display_names = [lang[0] for lang in Languages.LANGUAGES]
268
+ language_selection = questionary.select(
269
+ "Select a language for commit messages:", choices=display_names, use_shortcuts=True, use_arrow_keys=True
270
+ ).ask()
271
+
272
+ if not language_selection:
273
+ click.echo("Language selection cancelled. Using English (default).")
274
+ elif language_selection == "English":
275
+ set_key(str(GAC_ENV_PATH), "GAC_LANGUAGE", "English")
276
+ set_key(str(GAC_ENV_PATH), "GAC_TRANSLATE_PREFIXES", "false")
277
+ click.echo("Set GAC_LANGUAGE=English")
278
+ click.echo("Set GAC_TRANSLATE_PREFIXES=false")
279
+ else:
280
+ # Handle custom input
281
+ if language_selection == "Custom":
282
+ custom_language = questionary.text(
283
+ "Enter the language name (e.g., 'Spanish', 'Français', '日本語'):"
284
+ ).ask()
285
+ if not custom_language or not custom_language.strip():
286
+ click.echo("No language entered. Using English (default).")
287
+ language_value = None
288
+ else:
289
+ language_value = custom_language.strip()
290
+ else:
291
+ # Find the English name for the selected language
292
+ language_value = next(lang[1] for lang in Languages.LANGUAGES if lang[0] == language_selection)
293
+
294
+ if language_value:
295
+ # Ask about prefix translation
296
+ prefix_choice = questionary.select(
297
+ "How should conventional commit prefixes be handled?",
298
+ choices=[
299
+ "Keep prefixes in English (feat:, fix:, etc.)",
300
+ f"Translate prefixes into {language_value}",
301
+ ],
302
+ ).ask()
303
+
304
+ if not prefix_choice:
305
+ click.echo("Prefix translation selection cancelled. Using English prefixes.")
306
+ translate_prefixes = False
307
+ else:
308
+ translate_prefixes = prefix_choice.startswith("Translate prefixes")
309
+
310
+ # Set the language and prefix translation preference
311
+ set_key(str(GAC_ENV_PATH), "GAC_LANGUAGE", language_value)
312
+ set_key(str(GAC_ENV_PATH), "GAC_TRANSLATE_PREFIXES", "true" if translate_prefixes else "false")
313
+ click.echo(f"Set GAC_LANGUAGE={language_value}")
314
+ click.echo(f"Set GAC_TRANSLATE_PREFIXES={'true' if translate_prefixes else 'false'}")
315
+
316
+ click.echo(f"\ngac environment setup complete. You can edit {GAC_ENV_PATH} to update values later.")
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
 
5
5
  import click
6
6
  import questionary
7
- from dotenv import set_key, unset_key
7
+ from dotenv import set_key
8
8
 
9
9
  from gac.constants import Languages
10
10
 
@@ -30,14 +30,14 @@ def language() -> None:
30
30
  GAC_ENV_PATH.touch()
31
31
  click.echo(f"Created {GAC_ENV_PATH}")
32
32
 
33
- # Handle English (default) - remove the setting
33
+ # Handle English - set explicitly
34
34
  if selection == "English":
35
- try:
36
- unset_key(str(GAC_ENV_PATH), "GAC_LANGUAGE")
37
- click.echo("✓ Set language to English (default)")
38
- click.echo(f" Removed GAC_LANGUAGE from {GAC_ENV_PATH}")
39
- except Exception:
40
- click.echo(" Set language to English (default)")
35
+ set_key(str(GAC_ENV_PATH), "GAC_LANGUAGE", "English")
36
+ set_key(str(GAC_ENV_PATH), "GAC_TRANSLATE_PREFIXES", "false")
37
+ click.echo("✓ Set language to English")
38
+ click.echo(" GAC_LANGUAGE=English")
39
+ click.echo(" GAC_TRANSLATE_PREFIXES=false")
40
+ click.echo(f"\n Configuration saved to {GAC_ENV_PATH}")
41
41
  return
42
42
 
43
43
  # Handle custom input