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/providers/zai.py CHANGED
@@ -1,10 +1,15 @@
1
1
  """Z.AI API provider for gac."""
2
2
 
3
+ import logging
3
4
  import os
4
5
 
5
6
  import httpx
6
7
 
8
+ from gac.constants import ProviderDefaults
7
9
  from gac.errors import AIError
10
+ from gac.utils import get_ssl_verify
11
+
12
+ logger = logging.getLogger(__name__)
8
13
 
9
14
 
10
15
  def _call_zai_api_impl(
@@ -18,8 +23,12 @@ def _call_zai_api_impl(
18
23
  headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
19
24
  data = {"model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens}
20
25
 
26
+ logger.debug(f"Calling {api_name} API with model={model}")
27
+
21
28
  try:
22
- response = httpx.post(url, headers=headers, json=data, timeout=120)
29
+ response = httpx.post(
30
+ url, headers=headers, json=data, timeout=ProviderDefaults.HTTP_TIMEOUT, verify=get_ssl_verify()
31
+ )
23
32
  response.raise_for_status()
24
33
  response_data = response.json()
25
34
 
@@ -32,6 +41,7 @@ def _call_zai_api_impl(
32
41
  raise AIError.model_error(f"{api_name} API returned null content")
33
42
  if content == "":
34
43
  raise AIError.model_error(f"{api_name} API returned empty content")
44
+ logger.debug(f"{api_name} API response received successfully")
35
45
  return content
36
46
  else:
37
47
  raise AIError.model_error(f"{api_name} API response missing content: {response_data}")
gac/security.py CHANGED
@@ -54,7 +54,7 @@ class SecretPatterns:
54
54
  PRIVATE_KEY = re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")
55
55
 
56
56
  # Bearer Tokens (require actual token with specific characteristics)
57
- BEARER_TOKEN = re.compile(r"Bearer\s+[A-Za-z0-9]{20,}[/=]*\s", re.IGNORECASE)
57
+ BEARER_TOKEN = re.compile(r"Bearer\s+[A-Za-z0-9]{20,}[/=]*(?:\s|$)", re.IGNORECASE)
58
58
 
59
59
  # JWT Tokens
60
60
  JWT_TOKEN = re.compile(r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+")
gac/utils.py CHANGED
@@ -1,15 +1,46 @@
1
1
  """Utility functions for gac."""
2
2
 
3
+ import locale
3
4
  import logging
5
+ import os
4
6
  import subprocess
7
+ import sys
8
+ from functools import lru_cache
5
9
 
6
10
  from rich.console import Console
7
11
  from rich.theme import Theme
8
12
 
9
- from gac.constants import Logging
13
+ from gac.constants import EnvDefaults, Logging
10
14
  from gac.errors import GacError
11
15
 
12
16
 
17
+ @lru_cache(maxsize=1)
18
+ def should_skip_ssl_verification() -> bool:
19
+ """Return True when SSL certificate verification should be skipped.
20
+
21
+ This is useful for corporate environments with proxy servers that
22
+ intercept SSL traffic and cause certificate verification failures.
23
+
24
+ Can be enabled via:
25
+ - GAC_NO_VERIFY_SSL=true environment variable
26
+ - --no-verify-ssl CLI flag (which sets the env var)
27
+
28
+ Returns:
29
+ True if SSL verification should be skipped, False otherwise.
30
+ """
31
+ value = os.getenv("GAC_NO_VERIFY_SSL", str(EnvDefaults.NO_VERIFY_SSL))
32
+ return value.lower() in ("true", "1", "yes", "on")
33
+
34
+
35
+ def get_ssl_verify() -> bool:
36
+ """Get the SSL verification setting for httpx requests.
37
+
38
+ Returns:
39
+ True to verify SSL certificates (default), False to skip verification.
40
+ """
41
+ return not should_skip_ssl_verification()
42
+
43
+
13
44
  def setup_logging(
14
45
  log_level: int | str = Logging.DEFAULT_LEVEL,
15
46
  quiet: bool = False,
@@ -65,18 +96,47 @@ def print_message(message: str, level: str = "info") -> None:
65
96
  console.print(message, style=level)
66
97
 
67
98
 
68
- def run_subprocess(
99
+ def get_safe_encodings() -> list[str]:
100
+ """Get a list of safe encodings to try for subprocess calls, in order of preference.
101
+
102
+ Returns:
103
+ List of encoding strings to try, with UTF-8 first
104
+ """
105
+ encodings = ["utf-8"]
106
+
107
+ # Add locale encoding as fallback
108
+ locale_encoding = locale.getpreferredencoding(False)
109
+ if locale_encoding and locale_encoding not in encodings:
110
+ encodings.append(locale_encoding)
111
+
112
+ # Windows-specific fallbacks
113
+ if sys.platform == "win32":
114
+ windows_encodings = ["cp65001", "cp936", "cp1252"] # UTF-8, GBK, Windows-1252
115
+ for enc in windows_encodings:
116
+ if enc not in encodings:
117
+ encodings.append(enc)
118
+
119
+ # Final fallback to system default
120
+ if "utf-8" not in encodings:
121
+ encodings.append("utf-8")
122
+
123
+ return encodings
124
+
125
+
126
+ def run_subprocess_with_encoding(
69
127
  command: list[str],
128
+ encoding: str,
70
129
  silent: bool = False,
71
130
  timeout: int = 60,
72
131
  check: bool = True,
73
132
  strip_output: bool = True,
74
133
  raise_on_error: bool = True,
75
134
  ) -> str:
76
- """Run a subprocess command safely and return the output.
135
+ """Run subprocess with a specific encoding, handling encoding errors gracefully.
77
136
 
78
137
  Args:
79
138
  command: List of command arguments
139
+ encoding: Specific encoding to use
80
140
  silent: If True, suppress debug logging
81
141
  timeout: Command timeout in seconds
82
142
  check: Whether to check return code (for compatibility)
@@ -91,7 +151,7 @@ def run_subprocess(
91
151
  subprocess.CalledProcessError: If the command fails and raise_on_error is True
92
152
  """
93
153
  if not silent:
94
- logger.debug(f"Running command: {' '.join(command)}")
154
+ logger.debug(f"Running command: {' '.join(command)} (encoding: {encoding})")
95
155
 
96
156
  try:
97
157
  result = subprocess.run(
@@ -100,6 +160,8 @@ def run_subprocess(
100
160
  text=True,
101
161
  check=False,
102
162
  timeout=timeout,
163
+ encoding=encoding,
164
+ errors="replace", # Replace problematic characters instead of crashing
103
165
  )
104
166
 
105
167
  should_raise = result.returncode != 0 and (check or raise_on_error)
@@ -123,6 +185,11 @@ def run_subprocess(
123
185
  if raise_on_error:
124
186
  raise
125
187
  return ""
188
+ except UnicodeError as e:
189
+ # This should be rare with errors="replace", but handle it just in case
190
+ if not silent:
191
+ logger.debug(f"Encoding error with {encoding}: {e}")
192
+ raise
126
193
  except Exception as e:
127
194
  if not silent:
128
195
  logger.debug(f"Command error: {e}")
@@ -130,3 +197,204 @@ def run_subprocess(
130
197
  # Convert generic exceptions to CalledProcessError for consistency
131
198
  raise subprocess.CalledProcessError(1, command, "", str(e)) from e
132
199
  return ""
200
+
201
+
202
+ def run_subprocess(
203
+ command: list[str],
204
+ silent: bool = False,
205
+ timeout: int = 60,
206
+ check: bool = True,
207
+ strip_output: bool = True,
208
+ raise_on_error: bool = True,
209
+ ) -> str:
210
+ """Run a subprocess command safely and return the output, trying multiple encodings.
211
+
212
+ Args:
213
+ command: List of command arguments
214
+ silent: If True, suppress debug logging
215
+ timeout: Command timeout in seconds
216
+ check: Whether to check return code (for compatibility)
217
+ strip_output: Whether to strip whitespace from output
218
+ raise_on_error: Whether to raise an exception on error
219
+
220
+ Returns:
221
+ Command output as string
222
+
223
+ Raises:
224
+ GacError: If the command times out
225
+ subprocess.CalledProcessError: If the command fails and raise_on_error is True
226
+
227
+ Note:
228
+ Tries multiple encodings in order: utf-8, locale encoding, platform-specific fallbacks
229
+ This prevents UnicodeDecodeError on systems with non-UTF-8 locales (e.g., Chinese Windows)
230
+ """
231
+ encodings = get_safe_encodings()
232
+ last_exception = None
233
+
234
+ for encoding in encodings:
235
+ try:
236
+ return run_subprocess_with_encoding(
237
+ command=command,
238
+ encoding=encoding,
239
+ silent=silent,
240
+ timeout=timeout,
241
+ check=check,
242
+ strip_output=strip_output,
243
+ raise_on_error=raise_on_error,
244
+ )
245
+ except UnicodeError as e:
246
+ last_exception = e
247
+ if not silent:
248
+ logger.debug(f"Failed to decode with {encoding}: {e}")
249
+ continue
250
+ except (subprocess.CalledProcessError, GacError, subprocess.TimeoutExpired):
251
+ # These are not encoding-related errors, so don't retry with other encodings
252
+ raise
253
+
254
+ # If we get here, all encodings failed with UnicodeError
255
+ if not silent:
256
+ logger.error(f"Failed to decode command output with any encoding: {encodings}")
257
+
258
+ # Raise the last UnicodeError we encountered
259
+ if last_exception:
260
+ raise subprocess.CalledProcessError(1, command, "", f"Encoding error: {last_exception}") from last_exception
261
+ else:
262
+ raise subprocess.CalledProcessError(1, command, "", "All encoding attempts failed")
263
+
264
+
265
+ def edit_commit_message_inplace(message: str) -> str | None:
266
+ """Edit commit message in-place using rich terminal editing.
267
+
268
+ Uses prompt_toolkit to provide a rich editing experience with:
269
+ - Multi-line editing
270
+ - Vi/Emacs key bindings
271
+ - Line editing capabilities
272
+ - Esc+Enter or Ctrl+S to submit
273
+ - Ctrl+C to cancel
274
+
275
+ Args:
276
+ message: The initial commit message
277
+
278
+ Returns:
279
+ The edited commit message, or None if editing was cancelled
280
+
281
+ Example:
282
+ >>> edited = edit_commit_message_inplace("feat: add feature")
283
+ >>> # User can edit the message using vi/emacs key bindings
284
+ >>> # Press Esc+Enter or Ctrl+S to submit
285
+ """
286
+ from prompt_toolkit import Application
287
+ from prompt_toolkit.buffer import Buffer
288
+ from prompt_toolkit.document import Document
289
+ from prompt_toolkit.enums import EditingMode
290
+ from prompt_toolkit.key_binding import KeyBindings
291
+ from prompt_toolkit.layout import HSplit, Layout, Window
292
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
293
+ from prompt_toolkit.layout.margins import ScrollbarMargin
294
+ from prompt_toolkit.styles import Style
295
+
296
+ try:
297
+ console.print("\n[info]Edit commit message:[/info]")
298
+ console.print()
299
+
300
+ # Create buffer for text editing
301
+ text_buffer = Buffer(
302
+ document=Document(text=message, cursor_position=0),
303
+ multiline=True,
304
+ enable_history_search=False,
305
+ )
306
+
307
+ # Track submission state
308
+ cancelled = {"value": False}
309
+ submitted = {"value": False}
310
+
311
+ # Create text editor window
312
+ text_window = Window(
313
+ content=BufferControl(
314
+ buffer=text_buffer,
315
+ focus_on_click=True,
316
+ ),
317
+ height=lambda: max(5, message.count("\n") + 3),
318
+ wrap_lines=True,
319
+ right_margins=[ScrollbarMargin()],
320
+ )
321
+
322
+ # Create hint window
323
+ hint_window = Window(
324
+ content=FormattedTextControl(
325
+ text=[("class:hint", " Esc+Enter or Ctrl+S to submit | Ctrl+C to cancel ")],
326
+ ),
327
+ height=1,
328
+ dont_extend_height=True,
329
+ )
330
+
331
+ # Create layout
332
+ root_container = HSplit(
333
+ [
334
+ text_window,
335
+ hint_window,
336
+ ]
337
+ )
338
+
339
+ layout = Layout(root_container, focused_element=text_window)
340
+
341
+ # Create key bindings
342
+ kb = KeyBindings()
343
+
344
+ @kb.add("c-s")
345
+ def _(event):
346
+ """Submit with Ctrl+S."""
347
+ submitted["value"] = True
348
+ event.app.exit()
349
+
350
+ @kb.add("c-c")
351
+ def _(event):
352
+ """Cancel editing."""
353
+ cancelled["value"] = True
354
+ event.app.exit()
355
+
356
+ @kb.add("escape", "enter")
357
+ def _(event):
358
+ """Submit with Esc+Enter."""
359
+ submitted["value"] = True
360
+ event.app.exit()
361
+
362
+ # Create and run application
363
+ custom_style = Style.from_dict(
364
+ {
365
+ "hint": "#888888",
366
+ }
367
+ )
368
+
369
+ app: Application[None] = Application(
370
+ layout=layout,
371
+ key_bindings=kb,
372
+ full_screen=False,
373
+ mouse_support=False,
374
+ editing_mode=EditingMode.VI, # Enable vi key bindings
375
+ style=custom_style,
376
+ )
377
+
378
+ app.run()
379
+
380
+ # Handle result
381
+ if cancelled["value"]:
382
+ console.print("\n[yellow]Edit cancelled.[/yellow]")
383
+ return None
384
+
385
+ if submitted["value"]:
386
+ edited_message = text_buffer.text.strip()
387
+ if not edited_message:
388
+ console.print("[yellow]Commit message cannot be empty. Edit cancelled.[/yellow]")
389
+ return None
390
+ return edited_message
391
+
392
+ return None
393
+
394
+ except (EOFError, KeyboardInterrupt):
395
+ console.print("\n[yellow]Edit cancelled.[/yellow]")
396
+ return None
397
+ except Exception as e:
398
+ logger.error(f"Error during in-place editing: {e}")
399
+ console.print(f"[error]Failed to edit commit message: {e}[/error]")
400
+ return None
gac/workflow_utils.py ADDED
@@ -0,0 +1,217 @@
1
+ import logging
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from prompt_toolkit import prompt
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+
10
+ from gac.constants import EnvDefaults
11
+
12
+ logger = logging.getLogger(__name__)
13
+ console = Console()
14
+
15
+
16
+ def handle_confirmation_loop(
17
+ commit_message: str,
18
+ conversation_messages: list[dict[str, str]],
19
+ quiet: bool,
20
+ model: str,
21
+ ) -> tuple[str, str, list[dict[str, str]]]:
22
+ from gac.utils import edit_commit_message_inplace
23
+
24
+ while True:
25
+ response = click.prompt(
26
+ "Proceed with commit above? [y/n/r/e/<feedback>]",
27
+ type=str,
28
+ show_default=False,
29
+ ).strip()
30
+ response_lower = response.lower()
31
+
32
+ if response_lower in ["y", "yes"]:
33
+ return ("yes", commit_message, conversation_messages)
34
+ if response_lower in ["n", "no"]:
35
+ return ("no", commit_message, conversation_messages)
36
+ if response == "":
37
+ continue
38
+ if response_lower in ["e", "edit"]:
39
+ edited_message = edit_commit_message_inplace(commit_message)
40
+ if edited_message:
41
+ commit_message = edited_message
42
+ conversation_messages[-1] = {"role": "assistant", "content": commit_message}
43
+ logger.info("Commit message edited by user")
44
+ console.print("\n[bold green]Edited commit message:[/bold green]")
45
+ console.print(Panel(commit_message, title="Commit Message", border_style="cyan"))
46
+ else:
47
+ console.print("[yellow]Using previous message.[/yellow]")
48
+ console.print(Panel(commit_message, title="Commit Message", border_style="cyan"))
49
+ continue
50
+ if response_lower in ["r", "reroll"]:
51
+ msg = "Please provide an alternative commit message using the same repository context."
52
+ conversation_messages.append({"role": "user", "content": msg})
53
+ console.print("[cyan]Regenerating commit message...[/cyan]")
54
+ return ("regenerate", commit_message, conversation_messages)
55
+
56
+ msg = f"Please revise the commit message based on this feedback: {response}"
57
+ conversation_messages.append({"role": "user", "content": msg})
58
+ console.print(f"[cyan]Regenerating commit message with feedback: {response}[/cyan]")
59
+ return ("regenerate", commit_message, conversation_messages)
60
+
61
+
62
+ def execute_commit(commit_message: str, no_verify: bool, hook_timeout: int | None = None) -> None:
63
+ from gac.git import run_git_command
64
+
65
+ commit_args = ["commit", "-m", commit_message]
66
+ if no_verify:
67
+ commit_args.append("--no-verify")
68
+ effective_timeout = hook_timeout if hook_timeout and hook_timeout > 0 else EnvDefaults.HOOK_TIMEOUT
69
+ run_git_command(commit_args, timeout=effective_timeout)
70
+ logger.info("Commit created successfully")
71
+ console.print("[green]Commit created successfully[/green]")
72
+
73
+
74
+ def check_token_warning(
75
+ prompt_tokens: int,
76
+ warning_limit: int,
77
+ require_confirmation: bool,
78
+ ) -> bool:
79
+ if warning_limit and prompt_tokens > warning_limit:
80
+ console.print(
81
+ f"[yellow]⚠️ WARNING: Prompt has {prompt_tokens} tokens (warning threshold: {warning_limit})[/yellow]"
82
+ )
83
+ if require_confirmation:
84
+ proceed = click.confirm("Do you want to continue anyway?", default=True)
85
+ if not proceed:
86
+ console.print("[yellow]Aborted due to large token count.[/yellow]")
87
+ return False
88
+ return True
89
+
90
+
91
+ def display_commit_message(commit_message: str, prompt_tokens: int, model: str, quiet: bool) -> None:
92
+ from gac.ai_utils import count_tokens
93
+
94
+ console.print("[bold green]Generated commit message:[/bold green]")
95
+ console.print(Panel(commit_message, title="Commit Message", border_style="cyan"))
96
+
97
+ if not quiet:
98
+ completion_tokens = count_tokens(commit_message, model)
99
+ total_tokens = prompt_tokens + completion_tokens
100
+ console.print(
101
+ f"[dim]Token usage: {prompt_tokens} prompt + {completion_tokens} completion = {total_tokens} total[/dim]"
102
+ )
103
+
104
+
105
+ def restore_staging(staged_files: list[str], staged_diff: str | None = None) -> None:
106
+ """Restore the git staging area to a previous state.
107
+
108
+ Args:
109
+ staged_files: List of file paths that should be staged
110
+ staged_diff: Optional staged diff to reapply for partial staging
111
+ """
112
+ from gac.git import run_git_command
113
+
114
+ run_git_command(["reset", "HEAD"])
115
+
116
+ if staged_diff:
117
+ temp_path: Path | None = None
118
+ try:
119
+ with tempfile.NamedTemporaryFile("w", delete=False) as tmp:
120
+ tmp.write(staged_diff)
121
+ temp_path = Path(tmp.name)
122
+ run_git_command(["apply", "--cached", str(temp_path)])
123
+ return
124
+ except Exception as e:
125
+ logger.warning(f"Failed to reapply staged diff, falling back to file list: {e}")
126
+ finally:
127
+ if temp_path:
128
+ temp_path.unlink(missing_ok=True)
129
+
130
+ for file_path in staged_files:
131
+ try:
132
+ run_git_command(["add", file_path])
133
+ except Exception as e:
134
+ logger.warning(f"Failed to restore staging for {file_path}: {e}")
135
+
136
+
137
+ def collect_interactive_answers(questions: list[str]) -> dict[str, str] | None:
138
+ """Collect user answers to generated questions interactively.
139
+
140
+ Args:
141
+ questions: List of generated questions
142
+
143
+ Returns:
144
+ Dictionary mapping questions to answers, or None if user aborted
145
+ """
146
+ if not questions:
147
+ return {}
148
+
149
+ console.print("\n[bold cyan]🤝 Let's clarify your commit intent:[/bold cyan]")
150
+ console.print("[dim]Answer each question, press Enter to skip, or type:[/dim]")
151
+ console.print("[dim] • 'skip' - skip remaining questions[/dim]")
152
+ console.print("[dim] • 'quit' - abort interactive mode[/dim]\n")
153
+
154
+ answers = {}
155
+
156
+ for i, question in enumerate(questions, 1):
157
+ # Display the question with nice formatting
158
+ console.print(f"[bold blue]Question {i}:[/bold blue] {question}")
159
+
160
+ try:
161
+ answer = prompt("Your answer: ").strip()
162
+
163
+ # Handle special commands
164
+ answer_lower = answer.lower()
165
+
166
+ if answer_lower == "quit":
167
+ console.print("\n[yellow]⚠️ Interactive mode aborted by user[/yellow]")
168
+ return None
169
+ elif answer_lower == "skip":
170
+ console.print("[dim]Skipping remaining questions...[/dim]")
171
+ break
172
+ elif answer_lower == "" or answer_lower == "none":
173
+ # User explicitly skipped this question
174
+ console.print("[dim]↳ Skipped[/dim]")
175
+ continue
176
+ else:
177
+ # Valid answer provided
178
+ answers[question] = answer
179
+ console.print("[dim]↳ Got it![/dim]")
180
+
181
+ except KeyboardInterrupt:
182
+ # User pressed Ctrl+C
183
+ console.print("\n[yellow]⚠️ Interactive mode aborted by user[/yellow]")
184
+ return None
185
+
186
+ console.print() # Add spacing between questions
187
+
188
+ return answers
189
+
190
+
191
+ def format_answers_for_prompt(answers: dict[str, str]) -> str:
192
+ """Format collected answers for inclusion in the commit message prompt.
193
+
194
+ Args:
195
+ answers: Dictionary mapping questions to answers
196
+
197
+ Returns:
198
+ Formatted string for inclusion in the prompt
199
+ """
200
+ if not answers:
201
+ return ""
202
+
203
+ formatted_lines = []
204
+ for question, answer in answers.items():
205
+ formatted_lines.append(f"Q: {question}")
206
+ formatted_lines.append(f"A: {answer}")
207
+ formatted_lines.append("")
208
+
209
+ answers_text = "\n".join(formatted_lines).rstrip()
210
+
211
+ return (
212
+ f"\n\n<user_answers>\n"
213
+ f"The user provided the following clarifying information:\n\n"
214
+ f"{answers_text}\n\n"
215
+ f"</user_answers>\n\n"
216
+ f"<context_request>Use the user's answers above to craft a more accurate and informative commit message that captures their specific intent and context.</context_request>"
217
+ )