gac 1.13.0__py3-none-any.whl → 3.6.0__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.
- gac/__version__.py +1 -1
- gac/ai.py +33 -47
- gac/ai_utils.py +66 -41
- gac/auth_cli.py +69 -0
- gac/cli.py +59 -2
- gac/config.py +9 -6
- gac/config_cli.py +26 -5
- gac/constants.py +171 -2
- gac/git.py +158 -12
- gac/init_cli.py +40 -125
- gac/language_cli.py +378 -0
- gac/main.py +827 -159
- gac/model_cli.py +374 -0
- gac/oauth/__init__.py +1 -0
- gac/oauth/claude_code.py +397 -0
- gac/preprocess.py +3 -3
- gac/prompt.py +561 -226
- gac/providers/__init__.py +46 -0
- gac/providers/azure_openai.py +97 -0
- gac/providers/claude_code.py +102 -0
- gac/providers/custom_anthropic.py +1 -1
- gac/providers/custom_openai.py +1 -2
- gac/providers/kimi_coding.py +63 -0
- gac/providers/mistral.py +38 -0
- gac/providers/moonshot.py +38 -0
- gac/providers/replicate.py +98 -0
- gac/security.py +1 -1
- gac/utils.py +242 -3
- gac/workflow_utils.py +222 -0
- {gac-1.13.0.dist-info → gac-3.6.0.dist-info}/METADATA +87 -26
- gac-3.6.0.dist-info/RECORD +53 -0
- {gac-1.13.0.dist-info → gac-3.6.0.dist-info}/WHEEL +1 -1
- gac-1.13.0.dist-info/RECORD +0 -41
- {gac-1.13.0.dist-info → gac-3.6.0.dist-info}/entry_points.txt +0 -0
- {gac-1.13.0.dist-info → gac-3.6.0.dist-info}/licenses/LICENSE +0 -0
gac/constants.py
CHANGED
|
@@ -20,11 +20,13 @@ class EnvDefaults:
|
|
|
20
20
|
|
|
21
21
|
MAX_RETRIES: int = 3
|
|
22
22
|
TEMPERATURE: float = 1
|
|
23
|
-
MAX_OUTPUT_TOKENS: int =
|
|
24
|
-
WARNING_LIMIT_TOKENS: int =
|
|
23
|
+
MAX_OUTPUT_TOKENS: int = 4096 # includes reasoning tokens
|
|
24
|
+
WARNING_LIMIT_TOKENS: int = 32768
|
|
25
25
|
ALWAYS_INCLUDE_SCOPE: bool = False
|
|
26
26
|
SKIP_SECRET_SCAN: bool = False
|
|
27
27
|
VERBOSE: bool = False
|
|
28
|
+
NO_TIKTOKEN: bool = False
|
|
29
|
+
HOOK_TIMEOUT: int = 120 # Timeout for pre-commit and lefthook hooks in seconds
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
class Logging:
|
|
@@ -150,3 +152,170 @@ class CodePatternImportance:
|
|
|
150
152
|
r"\+\s*(test|describe|it|should)\s*\(": 1.1, # Test definitions
|
|
151
153
|
r"\+\s*(assert|expect)": 1.0, # Assertions
|
|
152
154
|
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Languages:
|
|
158
|
+
"""Language code mappings and utilities."""
|
|
159
|
+
|
|
160
|
+
# Language code to full name mapping
|
|
161
|
+
# Supports ISO 639-1 codes and common variants
|
|
162
|
+
CODE_MAP: dict[str, str] = {
|
|
163
|
+
# English
|
|
164
|
+
"en": "English",
|
|
165
|
+
# Chinese
|
|
166
|
+
"zh": "Simplified Chinese",
|
|
167
|
+
"zh-cn": "Simplified Chinese",
|
|
168
|
+
"zh-hans": "Simplified Chinese",
|
|
169
|
+
"zh-tw": "Traditional Chinese",
|
|
170
|
+
"zh-hant": "Traditional Chinese",
|
|
171
|
+
# Japanese
|
|
172
|
+
"ja": "Japanese",
|
|
173
|
+
# Korean
|
|
174
|
+
"ko": "Korean",
|
|
175
|
+
# Spanish
|
|
176
|
+
"es": "Spanish",
|
|
177
|
+
# Portuguese
|
|
178
|
+
"pt": "Portuguese",
|
|
179
|
+
# French
|
|
180
|
+
"fr": "French",
|
|
181
|
+
# German
|
|
182
|
+
"de": "German",
|
|
183
|
+
# Russian
|
|
184
|
+
"ru": "Russian",
|
|
185
|
+
# Hindi
|
|
186
|
+
"hi": "Hindi",
|
|
187
|
+
# Italian
|
|
188
|
+
"it": "Italian",
|
|
189
|
+
# Polish
|
|
190
|
+
"pl": "Polish",
|
|
191
|
+
# Turkish
|
|
192
|
+
"tr": "Turkish",
|
|
193
|
+
# Dutch
|
|
194
|
+
"nl": "Dutch",
|
|
195
|
+
# Vietnamese
|
|
196
|
+
"vi": "Vietnamese",
|
|
197
|
+
# Thai
|
|
198
|
+
"th": "Thai",
|
|
199
|
+
# Indonesian
|
|
200
|
+
"id": "Indonesian",
|
|
201
|
+
# Swedish
|
|
202
|
+
"sv": "Swedish",
|
|
203
|
+
# Arabic
|
|
204
|
+
"ar": "Arabic",
|
|
205
|
+
# Hebrew
|
|
206
|
+
"he": "Hebrew",
|
|
207
|
+
# Greek
|
|
208
|
+
"el": "Greek",
|
|
209
|
+
# Danish
|
|
210
|
+
"da": "Danish",
|
|
211
|
+
# Norwegian
|
|
212
|
+
"no": "Norwegian",
|
|
213
|
+
"nb": "Norwegian",
|
|
214
|
+
"nn": "Norwegian",
|
|
215
|
+
# Finnish
|
|
216
|
+
"fi": "Finnish",
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
# List of languages with display names and English names for CLI selection
|
|
220
|
+
# Format: (display_name, english_name)
|
|
221
|
+
LANGUAGES: list[tuple[str, str]] = [
|
|
222
|
+
("English", "English"),
|
|
223
|
+
("简体中文", "Simplified Chinese"),
|
|
224
|
+
("繁體中文", "Traditional Chinese"),
|
|
225
|
+
("日本語", "Japanese"),
|
|
226
|
+
("한국어", "Korean"),
|
|
227
|
+
("Español", "Spanish"),
|
|
228
|
+
("Português", "Portuguese"),
|
|
229
|
+
("Français", "French"),
|
|
230
|
+
("Deutsch", "German"),
|
|
231
|
+
("Русский", "Russian"),
|
|
232
|
+
("हिन्दी", "Hindi"),
|
|
233
|
+
("Italiano", "Italian"),
|
|
234
|
+
("Polski", "Polish"),
|
|
235
|
+
("Türkçe", "Turkish"),
|
|
236
|
+
("Nederlands", "Dutch"),
|
|
237
|
+
("Tiếng Việt", "Vietnamese"),
|
|
238
|
+
("ไทย", "Thai"),
|
|
239
|
+
("Bahasa Indonesia", "Indonesian"),
|
|
240
|
+
("Svenska", "Swedish"),
|
|
241
|
+
("العربية", "Arabic"),
|
|
242
|
+
("עברית", "Hebrew"),
|
|
243
|
+
("Ελληνικά", "Greek"),
|
|
244
|
+
("Dansk", "Danish"),
|
|
245
|
+
("Norsk", "Norwegian"),
|
|
246
|
+
("Suomi", "Finnish"),
|
|
247
|
+
("Custom", "Custom"),
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
@staticmethod
|
|
251
|
+
def resolve_code(language: str) -> str:
|
|
252
|
+
"""Resolve a language code to its full name.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
language: Language name or code (e.g., 'Spanish', 'es', 'zh-CN')
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
Full language name (e.g., 'Spanish', 'Simplified Chinese')
|
|
259
|
+
|
|
260
|
+
If the input is already a full language name, it's returned as-is.
|
|
261
|
+
If it's a recognized code, it's converted to the full name.
|
|
262
|
+
Otherwise, the input is returned unchanged (for custom languages).
|
|
263
|
+
"""
|
|
264
|
+
# Normalize the code to lowercase for lookup
|
|
265
|
+
code_lower = language.lower().strip()
|
|
266
|
+
|
|
267
|
+
# Check if it's a recognized code
|
|
268
|
+
if code_lower in Languages.CODE_MAP:
|
|
269
|
+
return Languages.CODE_MAP[code_lower]
|
|
270
|
+
|
|
271
|
+
# Return as-is (could be a full name or custom language)
|
|
272
|
+
return language
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class CommitMessageConstants:
|
|
276
|
+
"""Constants for commit message generation and cleaning."""
|
|
277
|
+
|
|
278
|
+
# Conventional commit type prefixes
|
|
279
|
+
CONVENTIONAL_PREFIXES: list[str] = [
|
|
280
|
+
"feat",
|
|
281
|
+
"fix",
|
|
282
|
+
"docs",
|
|
283
|
+
"style",
|
|
284
|
+
"refactor",
|
|
285
|
+
"perf",
|
|
286
|
+
"test",
|
|
287
|
+
"build",
|
|
288
|
+
"ci",
|
|
289
|
+
"chore",
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
# XML tags that may leak from prompt templates into AI responses
|
|
293
|
+
XML_TAGS_TO_REMOVE: list[str] = [
|
|
294
|
+
"<git-status>",
|
|
295
|
+
"</git-status>",
|
|
296
|
+
"<git_status>",
|
|
297
|
+
"</git_status>",
|
|
298
|
+
"<git-diff>",
|
|
299
|
+
"</git-diff>",
|
|
300
|
+
"<git_diff>",
|
|
301
|
+
"</git_diff>",
|
|
302
|
+
"<repository_context>",
|
|
303
|
+
"</repository_context>",
|
|
304
|
+
"<instructions>",
|
|
305
|
+
"</instructions>",
|
|
306
|
+
"<format>",
|
|
307
|
+
"</format>",
|
|
308
|
+
"<conventions>",
|
|
309
|
+
"</conventions>",
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
# Indicators that mark the start of the actual commit message in AI responses
|
|
313
|
+
COMMIT_INDICATORS: list[str] = [
|
|
314
|
+
"# Your commit message:",
|
|
315
|
+
"Your commit message:",
|
|
316
|
+
"The commit message is:",
|
|
317
|
+
"Here's the commit message:",
|
|
318
|
+
"Commit message:",
|
|
319
|
+
"Final commit message:",
|
|
320
|
+
"# Commit Message",
|
|
321
|
+
]
|
gac/git.py
CHANGED
|
@@ -14,6 +14,63 @@ from gac.utils import run_subprocess
|
|
|
14
14
|
logger = logging.getLogger(__name__)
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def run_subprocess_with_encoding_fallback(
|
|
18
|
+
command: list[str], silent: bool = False, timeout: int = 60
|
|
19
|
+
) -> subprocess.CompletedProcess:
|
|
20
|
+
"""Run subprocess with encoding fallback, returning full CompletedProcess object.
|
|
21
|
+
|
|
22
|
+
This is used for cases where we need both stdout and stderr separately,
|
|
23
|
+
like pre-commit and lefthook hook execution.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
command: List of command arguments
|
|
27
|
+
silent: If True, suppress debug logging
|
|
28
|
+
timeout: Command timeout in seconds
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
CompletedProcess object with stdout, stderr, and returncode
|
|
32
|
+
"""
|
|
33
|
+
from gac.utils import get_safe_encodings
|
|
34
|
+
|
|
35
|
+
encodings = get_safe_encodings()
|
|
36
|
+
last_exception: Exception | None = None
|
|
37
|
+
|
|
38
|
+
for encoding in encodings:
|
|
39
|
+
try:
|
|
40
|
+
if not silent:
|
|
41
|
+
logger.debug(f"Running command: {' '.join(command)} (encoding: {encoding})")
|
|
42
|
+
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
command,
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
check=False,
|
|
48
|
+
timeout=timeout,
|
|
49
|
+
encoding=encoding,
|
|
50
|
+
errors="replace",
|
|
51
|
+
)
|
|
52
|
+
return result
|
|
53
|
+
except UnicodeError as e:
|
|
54
|
+
last_exception = e
|
|
55
|
+
if not silent:
|
|
56
|
+
logger.debug(f"Failed to decode with {encoding}: {e}")
|
|
57
|
+
continue
|
|
58
|
+
except subprocess.TimeoutExpired:
|
|
59
|
+
raise
|
|
60
|
+
except Exception as e:
|
|
61
|
+
if not silent:
|
|
62
|
+
logger.debug(f"Command error: {e}")
|
|
63
|
+
# Try next encoding for non-timeout errors
|
|
64
|
+
last_exception = e
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# If we get here, all encodings failed
|
|
68
|
+
if last_exception:
|
|
69
|
+
raise subprocess.CalledProcessError(1, command, "", f"Encoding error: {last_exception}") from last_exception
|
|
70
|
+
else:
|
|
71
|
+
raise subprocess.CalledProcessError(1, command, "", "All encoding attempts failed")
|
|
72
|
+
|
|
73
|
+
|
|
17
74
|
def run_git_command(args: list[str], silent: bool = False, timeout: int = 30) -> str:
|
|
18
75
|
"""Run a git command and return the output."""
|
|
19
76
|
command = ["git"] + args
|
|
@@ -50,6 +107,48 @@ def get_staged_files(file_type: str | None = None, existing_only: bool = False)
|
|
|
50
107
|
return []
|
|
51
108
|
|
|
52
109
|
|
|
110
|
+
def get_staged_status() -> str:
|
|
111
|
+
"""Get formatted status of staged files only, excluding unstaged/untracked files.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Formatted status string with M/A/D/R indicators
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
output = run_git_command(["diff", "--name-status", "--staged"])
|
|
118
|
+
if not output:
|
|
119
|
+
return "No changes staged for commit."
|
|
120
|
+
|
|
121
|
+
status_map = {
|
|
122
|
+
"M": "modified",
|
|
123
|
+
"A": "new file",
|
|
124
|
+
"D": "deleted",
|
|
125
|
+
"R": "renamed",
|
|
126
|
+
"C": "copied",
|
|
127
|
+
"T": "typechange",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
status_lines = ["Changes to be committed:"]
|
|
131
|
+
for line in output.splitlines():
|
|
132
|
+
line = line.strip()
|
|
133
|
+
if not line:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# Parse status line (e.g., "M\tfile.py" or "R100\told.py\tnew.py")
|
|
137
|
+
parts = line.split("\t")
|
|
138
|
+
if len(parts) < 2:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
change_type = parts[0][0] # First char is the status (M, A, D, R, etc.)
|
|
142
|
+
file_path = parts[-1] # Last part is the new/current file path
|
|
143
|
+
|
|
144
|
+
status_label = status_map.get(change_type, "modified")
|
|
145
|
+
status_lines.append(f"\t{status_label}: {file_path}")
|
|
146
|
+
|
|
147
|
+
return "\n".join(status_lines)
|
|
148
|
+
except GitError:
|
|
149
|
+
return "No changes staged for commit."
|
|
150
|
+
|
|
151
|
+
|
|
53
152
|
def get_diff(staged: bool = True, color: bool = True, commit1: str | None = None, commit2: str | None = None) -> str:
|
|
54
153
|
"""Get the diff between commits or working tree.
|
|
55
154
|
|
|
@@ -90,23 +189,23 @@ def get_diff(staged: bool = True, color: bool = True, commit1: str | None = None
|
|
|
90
189
|
|
|
91
190
|
def get_repo_root() -> str:
|
|
92
191
|
"""Get absolute path of repository root."""
|
|
93
|
-
result =
|
|
94
|
-
return result
|
|
192
|
+
result = run_git_command(["rev-parse", "--show-toplevel"])
|
|
193
|
+
return result
|
|
95
194
|
|
|
96
195
|
|
|
97
196
|
def get_current_branch() -> str:
|
|
98
197
|
"""Get name of current git branch."""
|
|
99
|
-
result =
|
|
100
|
-
return result
|
|
198
|
+
result = run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
199
|
+
return result
|
|
101
200
|
|
|
102
201
|
|
|
103
202
|
def get_commit_hash() -> str:
|
|
104
203
|
"""Get SHA-1 hash of current commit."""
|
|
105
|
-
result =
|
|
106
|
-
return result
|
|
204
|
+
result = run_git_command(["rev-parse", "HEAD"])
|
|
205
|
+
return result
|
|
107
206
|
|
|
108
207
|
|
|
109
|
-
def run_pre_commit_hooks() -> bool:
|
|
208
|
+
def run_pre_commit_hooks(hook_timeout: int = 120) -> bool:
|
|
110
209
|
"""Run pre-commit hooks if they exist.
|
|
111
210
|
|
|
112
211
|
Returns:
|
|
@@ -126,9 +225,9 @@ def run_pre_commit_hooks() -> bool:
|
|
|
126
225
|
return True
|
|
127
226
|
|
|
128
227
|
# Run pre-commit hooks on staged files
|
|
129
|
-
logger.info("Running pre-commit hooks...")
|
|
228
|
+
logger.info(f"Running pre-commit hooks with {hook_timeout}s timeout...")
|
|
130
229
|
# Run pre-commit and capture both stdout and stderr
|
|
131
|
-
result =
|
|
230
|
+
result = run_subprocess_with_encoding_fallback(["pre-commit", "run"], timeout=hook_timeout)
|
|
132
231
|
|
|
133
232
|
if result.returncode == 0:
|
|
134
233
|
# All hooks passed
|
|
@@ -153,7 +252,7 @@ def run_pre_commit_hooks() -> bool:
|
|
|
153
252
|
return True
|
|
154
253
|
|
|
155
254
|
|
|
156
|
-
def run_lefthook_hooks() -> bool:
|
|
255
|
+
def run_lefthook_hooks(hook_timeout: int = 120) -> bool:
|
|
157
256
|
"""Run Lefthook hooks if they exist.
|
|
158
257
|
|
|
159
258
|
Returns:
|
|
@@ -176,9 +275,9 @@ def run_lefthook_hooks() -> bool:
|
|
|
176
275
|
return True
|
|
177
276
|
|
|
178
277
|
# Run lefthook hooks on staged files
|
|
179
|
-
logger.info("Running Lefthook hooks...")
|
|
278
|
+
logger.info(f"Running Lefthook hooks with {hook_timeout}s timeout...")
|
|
180
279
|
# Run lefthook and capture both stdout and stderr
|
|
181
|
-
result =
|
|
280
|
+
result = run_subprocess_with_encoding_fallback(["lefthook", "run", "pre-commit"], timeout=hook_timeout)
|
|
182
281
|
|
|
183
282
|
if result.returncode == 0:
|
|
184
283
|
# All hooks passed
|
|
@@ -224,3 +323,50 @@ def push_changes() -> bool:
|
|
|
224
323
|
except Exception as e:
|
|
225
324
|
logger.error(f"Failed to push changes: {e}")
|
|
226
325
|
return False
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def detect_rename_mappings(staged_diff: str) -> dict[str, str]:
|
|
329
|
+
"""Detect file rename mappings from a staged diff.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
staged_diff: The output of 'git diff --cached --binary'
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Dictionary mapping new_file_path -> old_file_path for rename operations
|
|
336
|
+
"""
|
|
337
|
+
rename_mappings = {}
|
|
338
|
+
lines = staged_diff.split("\n")
|
|
339
|
+
|
|
340
|
+
i = 0
|
|
341
|
+
while i < len(lines):
|
|
342
|
+
line = lines[i]
|
|
343
|
+
|
|
344
|
+
if line.startswith("diff --git a/"):
|
|
345
|
+
# Extract old and new file paths from diff header
|
|
346
|
+
if " b/" in line:
|
|
347
|
+
parts = line.split(" a/")
|
|
348
|
+
if len(parts) >= 2:
|
|
349
|
+
old_path_part = parts[1]
|
|
350
|
+
old_path = old_path_part.split(" b/")[0] if " b/" in old_path_part else old_path_part
|
|
351
|
+
|
|
352
|
+
new_path = line.split(" b/")[-1] if " b/" in line else None
|
|
353
|
+
|
|
354
|
+
# Check if this diff represents a rename by looking at following lines
|
|
355
|
+
j = i + 1
|
|
356
|
+
is_rename = False
|
|
357
|
+
|
|
358
|
+
while j < len(lines) and not lines[j].startswith("diff --git"):
|
|
359
|
+
if lines[j].startswith("similarity index "):
|
|
360
|
+
is_rename = True
|
|
361
|
+
break
|
|
362
|
+
elif lines[j].startswith("rename from "):
|
|
363
|
+
is_rename = True
|
|
364
|
+
break
|
|
365
|
+
j += 1
|
|
366
|
+
|
|
367
|
+
if is_rename and old_path and new_path and old_path != new_path:
|
|
368
|
+
rename_mappings[new_path] = old_path
|
|
369
|
+
|
|
370
|
+
i += 1
|
|
371
|
+
|
|
372
|
+
return rename_mappings
|
gac/init_cli.py
CHANGED
|
@@ -4,7 +4,10 @@ from pathlib import Path
|
|
|
4
4
|
|
|
5
5
|
import click
|
|
6
6
|
import questionary
|
|
7
|
-
from dotenv import
|
|
7
|
+
from dotenv import dotenv_values
|
|
8
|
+
|
|
9
|
+
from gac.language_cli import configure_language_init_workflow
|
|
10
|
+
from gac.model_cli import _configure_model
|
|
8
11
|
|
|
9
12
|
GAC_ENV_PATH = Path.home() / ".gac.env"
|
|
10
13
|
|
|
@@ -21,134 +24,46 @@ def _prompt_required_text(prompt: str) -> str | None:
|
|
|
21
24
|
click.echo("A value is required. Please try again.")
|
|
22
25
|
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
click.echo("Welcome to gac initialization!\n")
|
|
27
|
+
def _load_existing_env() -> dict[str, str]:
|
|
28
|
+
"""Ensure the env file exists and return its current values."""
|
|
29
|
+
existing_env: dict[str, str] = {}
|
|
28
30
|
if GAC_ENV_PATH.exists():
|
|
29
31
|
click.echo(f"$HOME/.gac.env already exists at {GAC_ENV_PATH}. Values will be updated.")
|
|
32
|
+
existing_env = {k: v for k, v in dotenv_values(str(GAC_ENV_PATH)).items() if v is not None}
|
|
30
33
|
else:
|
|
31
34
|
GAC_ENV_PATH.touch()
|
|
32
35
|
click.echo(f"Created $HOME/.gac.env at {GAC_ENV_PATH}.")
|
|
36
|
+
return existing_env
|
|
33
37
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
("
|
|
44
|
-
("LM Studio", "gemma3"),
|
|
45
|
-
("MiniMax", "MiniMax-M2"),
|
|
46
|
-
("Ollama", "gemma3"),
|
|
47
|
-
("OpenAI", "gpt-4.1-mini"),
|
|
48
|
-
("OpenRouter", "openrouter/auto"),
|
|
49
|
-
("Streamlake", ""),
|
|
50
|
-
("Synthetic", "hf:zai-org/GLM-4.6"),
|
|
51
|
-
("Together AI", "openai/gpt-oss-20B"),
|
|
52
|
-
("Z.AI", "glm-4.5-air"),
|
|
53
|
-
("Z.AI Coding", "glm-4.6"),
|
|
54
|
-
]
|
|
55
|
-
provider_names = [p[0] for p in providers]
|
|
56
|
-
provider = questionary.select("Select your provider:", choices=provider_names).ask()
|
|
57
|
-
if not provider:
|
|
58
|
-
click.echo("Provider selection cancelled. Exiting.")
|
|
59
|
-
return
|
|
60
|
-
provider_key = provider.lower().replace(".", "").replace(" ", "-").replace("(", "").replace(")", "")
|
|
61
|
-
|
|
62
|
-
is_ollama = provider_key == "ollama"
|
|
63
|
-
is_lmstudio = provider_key == "lm-studio"
|
|
64
|
-
is_streamlake = provider_key == "streamlake"
|
|
65
|
-
is_zai = provider_key in ("zai", "zai-coding")
|
|
66
|
-
is_custom_anthropic = provider_key == "custom-anthropic"
|
|
67
|
-
is_custom_openai = provider_key == "custom-openai"
|
|
68
|
-
|
|
69
|
-
if is_streamlake:
|
|
70
|
-
endpoint_id = _prompt_required_text("Enter the Streamlake inference endpoint ID (required):")
|
|
71
|
-
if endpoint_id is None:
|
|
72
|
-
click.echo("Streamlake configuration cancelled. Exiting.")
|
|
73
|
-
return
|
|
74
|
-
model_to_save = endpoint_id
|
|
38
|
+
|
|
39
|
+
def _configure_language(existing_env: dict[str, str]) -> None:
|
|
40
|
+
"""Run the language configuration flow using consolidated logic."""
|
|
41
|
+
click.echo("\n")
|
|
42
|
+
|
|
43
|
+
# Use the consolidated language configuration from language_cli
|
|
44
|
+
success = configure_language_init_workflow(GAC_ENV_PATH)
|
|
45
|
+
|
|
46
|
+
if not success:
|
|
47
|
+
click.echo("Language configuration cancelled or failed.")
|
|
75
48
|
else:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
api_version = questionary.text(
|
|
99
|
-
"Enter the API version (optional, press Enter for default: 2023-06-01):", default="2023-06-01"
|
|
100
|
-
).ask()
|
|
101
|
-
if api_version and api_version != "2023-06-01":
|
|
102
|
-
set_key(str(GAC_ENV_PATH), "CUSTOM_ANTHROPIC_VERSION", api_version)
|
|
103
|
-
click.echo(f"Set CUSTOM_ANTHROPIC_VERSION={api_version}")
|
|
104
|
-
elif is_custom_openai:
|
|
105
|
-
base_url = _prompt_required_text("Enter the custom OpenAI-compatible base URL (required):")
|
|
106
|
-
if base_url is None:
|
|
107
|
-
click.echo("Custom OpenAI base URL entry cancelled. Exiting.")
|
|
108
|
-
return
|
|
109
|
-
set_key(str(GAC_ENV_PATH), "CUSTOM_OPENAI_BASE_URL", base_url)
|
|
110
|
-
click.echo(f"Set CUSTOM_OPENAI_BASE_URL={base_url}")
|
|
111
|
-
elif is_ollama:
|
|
112
|
-
url_default = "http://localhost:11434"
|
|
113
|
-
url = questionary.text(f"Enter the Ollama API URL (default: {url_default}):", default=url_default).ask()
|
|
114
|
-
if url is None:
|
|
115
|
-
click.echo("Ollama URL entry cancelled. Exiting.")
|
|
116
|
-
return
|
|
117
|
-
url_to_save = url.strip() if url.strip() else url_default
|
|
118
|
-
set_key(str(GAC_ENV_PATH), "OLLAMA_API_URL", url_to_save)
|
|
119
|
-
click.echo(f"Set OLLAMA_API_URL={url_to_save}")
|
|
120
|
-
elif is_lmstudio:
|
|
121
|
-
url_default = "http://localhost:1234"
|
|
122
|
-
url = questionary.text(f"Enter the LM Studio API URL (default: {url_default}):", default=url_default).ask()
|
|
123
|
-
if url is None:
|
|
124
|
-
click.echo("LM Studio URL entry cancelled. Exiting.")
|
|
125
|
-
return
|
|
126
|
-
url_to_save = url.strip() if url.strip() else url_default
|
|
127
|
-
set_key(str(GAC_ENV_PATH), "LMSTUDIO_API_URL", url_to_save)
|
|
128
|
-
click.echo(f"Set LMSTUDIO_API_URL={url_to_save}")
|
|
129
|
-
|
|
130
|
-
api_key_prompt = "Enter your API key (input hidden, can be set later):"
|
|
131
|
-
if is_ollama or is_lmstudio:
|
|
132
|
-
click.echo(
|
|
133
|
-
"This provider typically runs locally. API keys are optional unless your instance requires authentication."
|
|
134
|
-
)
|
|
135
|
-
api_key_prompt = "Enter your API key (optional, press Enter to skip):"
|
|
136
|
-
|
|
137
|
-
api_key = questionary.password(api_key_prompt).ask()
|
|
138
|
-
if api_key:
|
|
139
|
-
if is_lmstudio:
|
|
140
|
-
api_key_name = "LMSTUDIO_API_KEY"
|
|
141
|
-
elif is_zai:
|
|
142
|
-
api_key_name = "ZAI_API_KEY"
|
|
143
|
-
elif is_custom_anthropic:
|
|
144
|
-
api_key_name = "CUSTOM_ANTHROPIC_API_KEY"
|
|
145
|
-
elif is_custom_openai:
|
|
146
|
-
api_key_name = "CUSTOM_OPENAI_API_KEY"
|
|
147
|
-
else:
|
|
148
|
-
api_key_name = f"{provider_key.upper()}_API_KEY"
|
|
149
|
-
set_key(str(GAC_ENV_PATH), api_key_name, api_key)
|
|
150
|
-
click.echo(f"Set {api_key_name} (hidden)")
|
|
151
|
-
elif is_ollama or is_lmstudio:
|
|
152
|
-
click.echo("Skipping API key. You can add one later if needed.")
|
|
153
|
-
|
|
154
|
-
click.echo(f"\ngac environment setup complete. You can edit {GAC_ENV_PATH} to update values later.")
|
|
49
|
+
click.echo("Language configuration completed.")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@click.command()
|
|
53
|
+
def init() -> None:
|
|
54
|
+
"""Interactively set up $HOME/.gac.env for gac."""
|
|
55
|
+
click.echo("Welcome to gac initialization!\n")
|
|
56
|
+
|
|
57
|
+
existing_env = _load_existing_env()
|
|
58
|
+
|
|
59
|
+
if not _configure_model(existing_env):
|
|
60
|
+
click.echo("Model configuration cancelled. Exiting.")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
_configure_language(existing_env)
|
|
64
|
+
|
|
65
|
+
click.echo("\ngac environment setup complete 🎉")
|
|
66
|
+
click.echo("Configuration saved to:")
|
|
67
|
+
click.echo(f" {GAC_ENV_PATH}")
|
|
68
|
+
click.echo("\nYou can now run 'gac' in any Git repository to generate commit messages.")
|
|
69
|
+
click.echo("Run 'gac --help' to see available options.")
|