gitwit 0.1.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.
- gitwit/__init__.py +4 -0
- gitwit/ai.py +324 -0
- gitwit/cli.py +520 -0
- gitwit/config.py +169 -0
- gitwit/git.py +312 -0
- gitwit/license.py +124 -0
- gitwit/prompts.py +161 -0
- gitwit-0.1.0.dist-info/METADATA +201 -0
- gitwit-0.1.0.dist-info/RECORD +12 -0
- gitwit-0.1.0.dist-info/WHEEL +4 -0
- gitwit-0.1.0.dist-info/entry_points.txt +2 -0
- gitwit-0.1.0.dist-info/licenses/LICENSE +21 -0
gitwit/cli.py
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
"""CLI commands for GitWit."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .ai import AIError, AuthenticationError, generate_sync
|
|
11
|
+
from .config import (
|
|
12
|
+
get_api_key,
|
|
13
|
+
get_config_value,
|
|
14
|
+
get_model,
|
|
15
|
+
get_provider,
|
|
16
|
+
is_configured,
|
|
17
|
+
load_config,
|
|
18
|
+
set_config_value,
|
|
19
|
+
DEFAULT_MODELS,
|
|
20
|
+
)
|
|
21
|
+
from .git import (
|
|
22
|
+
GitError,
|
|
23
|
+
NoStagedChangesError,
|
|
24
|
+
NotAGitRepoError,
|
|
25
|
+
commit,
|
|
26
|
+
get_branch_diff,
|
|
27
|
+
get_current_branch,
|
|
28
|
+
get_default_branch,
|
|
29
|
+
get_staged_diff,
|
|
30
|
+
has_staged_changes,
|
|
31
|
+
is_git_repo,
|
|
32
|
+
parse_diff_stats,
|
|
33
|
+
stage_all_changes,
|
|
34
|
+
)
|
|
35
|
+
from .prompts import get_commit_prompt, get_pr_prompt
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def style_error(message: str) -> str:
|
|
39
|
+
"""Style an error message."""
|
|
40
|
+
return click.style(f"Error: {message}", fg="red")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def style_success(message: str) -> str:
|
|
44
|
+
"""Style a success message."""
|
|
45
|
+
return click.style(message, fg="green")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def style_info(message: str) -> str:
|
|
49
|
+
"""Style an info message."""
|
|
50
|
+
return click.style(message, fg="cyan")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def style_warning(message: str) -> str:
|
|
54
|
+
"""Style a warning message."""
|
|
55
|
+
return click.style(message, fg="yellow")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def style_commit(message: str) -> str:
|
|
59
|
+
"""Style a commit message."""
|
|
60
|
+
return click.style(message, fg="bright_white", bold=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@click.group(invoke_without_command=True)
|
|
64
|
+
@click.option("--version", "-v", is_flag=True, help="Show version and exit.")
|
|
65
|
+
@click.pass_context
|
|
66
|
+
def main(ctx: click.Context, version: bool) -> None:
|
|
67
|
+
"""GitWit - AI-powered git commit messages using free APIs."""
|
|
68
|
+
if version:
|
|
69
|
+
click.echo(f"gitwit {__version__}")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
if ctx.invoked_subcommand is None:
|
|
73
|
+
click.echo(ctx.get_help())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@main.command(name="commit")
|
|
77
|
+
@click.option("--all", "-a", "stage_all", is_flag=True, help="Stage all changes before committing.")
|
|
78
|
+
@click.option("--yes", "-y", is_flag=True, help="Accept the generated message without confirmation.")
|
|
79
|
+
@click.option("--dry-run", "-n", is_flag=True, help="Show the message without committing.")
|
|
80
|
+
def commit_cmd(stage_all: bool, yes: bool, dry_run: bool) -> None:
|
|
81
|
+
"""Generate a commit message from staged changes."""
|
|
82
|
+
# Check if we're in a git repo
|
|
83
|
+
if not is_git_repo():
|
|
84
|
+
click.echo(style_error("Not a git repository. Run 'git init' first."))
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
|
|
87
|
+
# Check configuration
|
|
88
|
+
if not is_configured():
|
|
89
|
+
click.echo(style_warning("GitWit is not configured yet."))
|
|
90
|
+
click.echo()
|
|
91
|
+
if click.confirm("Would you like to configure it now?"):
|
|
92
|
+
ctx = click.Context(config)
|
|
93
|
+
ctx.invoke(interactive_setup)
|
|
94
|
+
else:
|
|
95
|
+
click.echo("\nRun 'gitwit config set provider groq' to get started.")
|
|
96
|
+
sys.exit(1)
|
|
97
|
+
|
|
98
|
+
# Stage all changes if requested
|
|
99
|
+
if stage_all:
|
|
100
|
+
click.echo(style_info("Staging all changes..."))
|
|
101
|
+
stage_all_changes()
|
|
102
|
+
|
|
103
|
+
# Check for staged changes
|
|
104
|
+
if not has_staged_changes():
|
|
105
|
+
click.echo(style_error(
|
|
106
|
+
"No staged changes. Stage changes with 'git add' first, "
|
|
107
|
+
"or use 'gitwit commit --all' to stage all changes."
|
|
108
|
+
))
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
# Get the diff
|
|
112
|
+
try:
|
|
113
|
+
diff = get_staged_diff()
|
|
114
|
+
except NoStagedChangesError as e:
|
|
115
|
+
click.echo(style_error(str(e)))
|
|
116
|
+
sys.exit(1)
|
|
117
|
+
except GitError as e:
|
|
118
|
+
click.echo(style_error(str(e)))
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
|
|
121
|
+
# Show diff stats
|
|
122
|
+
stats = parse_diff_stats(diff)
|
|
123
|
+
click.echo(style_info(
|
|
124
|
+
f"Analyzing {len(stats.files_changed)} file(s): "
|
|
125
|
+
f"+{stats.additions} -{stats.deletions}"
|
|
126
|
+
))
|
|
127
|
+
|
|
128
|
+
# Generate commit message
|
|
129
|
+
provider = get_provider()
|
|
130
|
+
model = get_model()
|
|
131
|
+
click.echo(style_info(f"Generating commit message using {provider} ({model})..."))
|
|
132
|
+
click.echo()
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
system_prompt, user_prompt = get_commit_prompt(diff)
|
|
136
|
+
message = generate_sync(system_prompt, user_prompt)
|
|
137
|
+
|
|
138
|
+
# Clean up the message (remove quotes if present)
|
|
139
|
+
message = message.strip().strip('"').strip("'")
|
|
140
|
+
|
|
141
|
+
# Remove any trailing punctuation that shouldn't be there
|
|
142
|
+
if message.endswith('.'):
|
|
143
|
+
message = message[:-1]
|
|
144
|
+
|
|
145
|
+
except AuthenticationError as e:
|
|
146
|
+
click.echo(style_error(str(e)))
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
except AIError as e:
|
|
149
|
+
click.echo(style_error(str(e)))
|
|
150
|
+
sys.exit(1)
|
|
151
|
+
|
|
152
|
+
# Display the generated message
|
|
153
|
+
click.echo("Generated commit message:")
|
|
154
|
+
click.echo()
|
|
155
|
+
click.echo(f" {style_commit(message)}")
|
|
156
|
+
click.echo()
|
|
157
|
+
|
|
158
|
+
if dry_run:
|
|
159
|
+
click.echo(style_info("Dry run - no commit created."))
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
if yes:
|
|
163
|
+
# Auto-accept
|
|
164
|
+
action = "y"
|
|
165
|
+
else:
|
|
166
|
+
# Interactive prompt
|
|
167
|
+
click.echo("Options: [y]es, [e]dit, [r]egenerate, [n]o/cancel")
|
|
168
|
+
action = click.prompt(
|
|
169
|
+
"Accept this message?",
|
|
170
|
+
type=click.Choice(["y", "e", "r", "n"], case_sensitive=False),
|
|
171
|
+
default="y",
|
|
172
|
+
show_choices=False,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
while True:
|
|
176
|
+
if action == "y":
|
|
177
|
+
# Accept and commit
|
|
178
|
+
try:
|
|
179
|
+
output = commit(message)
|
|
180
|
+
click.echo()
|
|
181
|
+
click.echo(style_success("Committed successfully!"))
|
|
182
|
+
# Show abbreviated output
|
|
183
|
+
for line in output.strip().split('\n')[:3]:
|
|
184
|
+
click.echo(f" {line}")
|
|
185
|
+
except GitError as e:
|
|
186
|
+
click.echo(style_error(str(e)))
|
|
187
|
+
sys.exit(1)
|
|
188
|
+
break
|
|
189
|
+
|
|
190
|
+
elif action == "e":
|
|
191
|
+
# Edit the message
|
|
192
|
+
edited = click.edit(message)
|
|
193
|
+
if edited:
|
|
194
|
+
message = edited.strip()
|
|
195
|
+
click.echo()
|
|
196
|
+
click.echo("Edited message:")
|
|
197
|
+
click.echo(f" {style_commit(message)}")
|
|
198
|
+
click.echo()
|
|
199
|
+
action = click.prompt(
|
|
200
|
+
"Accept this message?",
|
|
201
|
+
type=click.Choice(["y", "e", "r", "n"], case_sensitive=False),
|
|
202
|
+
default="y",
|
|
203
|
+
show_choices=False,
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
click.echo(style_warning("Edit cancelled."))
|
|
207
|
+
action = click.prompt(
|
|
208
|
+
"Action?",
|
|
209
|
+
type=click.Choice(["y", "e", "r", "n"], case_sensitive=False),
|
|
210
|
+
default="y",
|
|
211
|
+
show_choices=False,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
elif action == "r":
|
|
215
|
+
# Regenerate
|
|
216
|
+
click.echo()
|
|
217
|
+
click.echo(style_info("Regenerating..."))
|
|
218
|
+
try:
|
|
219
|
+
message = generate_sync(system_prompt, user_prompt)
|
|
220
|
+
message = message.strip().strip('"').strip("'")
|
|
221
|
+
if message.endswith('.'):
|
|
222
|
+
message = message[:-1]
|
|
223
|
+
except AIError as e:
|
|
224
|
+
click.echo(style_error(str(e)))
|
|
225
|
+
sys.exit(1)
|
|
226
|
+
|
|
227
|
+
click.echo()
|
|
228
|
+
click.echo("Generated commit message:")
|
|
229
|
+
click.echo(f" {style_commit(message)}")
|
|
230
|
+
click.echo()
|
|
231
|
+
action = click.prompt(
|
|
232
|
+
"Accept this message?",
|
|
233
|
+
type=click.Choice(["y", "e", "r", "n"], case_sensitive=False),
|
|
234
|
+
default="y",
|
|
235
|
+
show_choices=False,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
elif action == "n":
|
|
239
|
+
click.echo(style_info("Commit cancelled."))
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@main.command(name="pr")
|
|
244
|
+
@click.option("--base", "-b", default=None, help="Base branch to compare against (default: main or master).")
|
|
245
|
+
@click.option("--dry-run", "-n", is_flag=True, help="Show the PR description without copying.")
|
|
246
|
+
def pr_cmd(base: str | None, dry_run: bool) -> None:
|
|
247
|
+
"""Generate a PR title and description from branch diff."""
|
|
248
|
+
# Check if we're in a git repo
|
|
249
|
+
if not is_git_repo():
|
|
250
|
+
click.echo(style_error("Not a git repository."))
|
|
251
|
+
sys.exit(1)
|
|
252
|
+
|
|
253
|
+
# Check configuration
|
|
254
|
+
if not is_configured():
|
|
255
|
+
click.echo(style_warning("GitWit is not configured yet."))
|
|
256
|
+
click.echo()
|
|
257
|
+
if click.confirm("Would you like to configure it now?"):
|
|
258
|
+
ctx = click.Context(config)
|
|
259
|
+
ctx.invoke(interactive_setup)
|
|
260
|
+
else:
|
|
261
|
+
click.echo("\nRun 'gitwit config set provider groq' to get started.")
|
|
262
|
+
sys.exit(1)
|
|
263
|
+
|
|
264
|
+
# Get current branch
|
|
265
|
+
current_branch = get_current_branch()
|
|
266
|
+
if not current_branch:
|
|
267
|
+
click.echo(style_error("Could not determine current branch."))
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
|
|
270
|
+
# Detect base branch if not specified
|
|
271
|
+
if base is None:
|
|
272
|
+
try:
|
|
273
|
+
base = get_default_branch()
|
|
274
|
+
except GitError:
|
|
275
|
+
click.echo(style_error("Could not detect default branch. Use --base to specify."))
|
|
276
|
+
sys.exit(1)
|
|
277
|
+
|
|
278
|
+
# Check if we're on the base branch
|
|
279
|
+
if current_branch == base:
|
|
280
|
+
click.echo(style_error(f"Already on {base} branch. Switch to a feature branch first."))
|
|
281
|
+
sys.exit(1)
|
|
282
|
+
|
|
283
|
+
click.echo(style_info(f"Comparing {current_branch} → {base}"))
|
|
284
|
+
|
|
285
|
+
# Get the diff
|
|
286
|
+
try:
|
|
287
|
+
diff = get_branch_diff(base)
|
|
288
|
+
except GitError as e:
|
|
289
|
+
click.echo(style_error(str(e)))
|
|
290
|
+
sys.exit(1)
|
|
291
|
+
|
|
292
|
+
if not diff.strip():
|
|
293
|
+
click.echo(style_error(f"No changes between {current_branch} and {base}."))
|
|
294
|
+
sys.exit(1)
|
|
295
|
+
|
|
296
|
+
# Show diff stats
|
|
297
|
+
stats = parse_diff_stats(diff)
|
|
298
|
+
click.echo(style_info(
|
|
299
|
+
f"Analyzing {len(stats.files_changed)} file(s): "
|
|
300
|
+
f"+{stats.additions} -{stats.deletions}"
|
|
301
|
+
))
|
|
302
|
+
|
|
303
|
+
# Generate PR description
|
|
304
|
+
provider = get_provider()
|
|
305
|
+
model = get_model()
|
|
306
|
+
click.echo(style_info(f"Generating PR description using {provider} ({model})..."))
|
|
307
|
+
click.echo()
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
system_prompt, user_prompt = get_pr_prompt(diff, current_branch)
|
|
311
|
+
description = generate_sync(system_prompt, user_prompt)
|
|
312
|
+
description = description.strip()
|
|
313
|
+
|
|
314
|
+
except AuthenticationError as e:
|
|
315
|
+
click.echo(style_error(str(e)))
|
|
316
|
+
sys.exit(1)
|
|
317
|
+
except AIError as e:
|
|
318
|
+
click.echo(style_error(str(e)))
|
|
319
|
+
sys.exit(1)
|
|
320
|
+
|
|
321
|
+
# Display the generated description
|
|
322
|
+
click.echo(style_commit("=" * 60))
|
|
323
|
+
click.echo()
|
|
324
|
+
click.echo(description)
|
|
325
|
+
click.echo()
|
|
326
|
+
click.echo(style_commit("=" * 60))
|
|
327
|
+
click.echo()
|
|
328
|
+
|
|
329
|
+
if dry_run:
|
|
330
|
+
click.echo(style_info("Dry run - PR description displayed above."))
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
# Offer to copy to clipboard
|
|
334
|
+
click.echo("Options: [c]opy to clipboard, [r]egenerate, [q]uit")
|
|
335
|
+
action = click.prompt(
|
|
336
|
+
"Action?",
|
|
337
|
+
type=click.Choice(["c", "r", "q"], case_sensitive=False),
|
|
338
|
+
default="c",
|
|
339
|
+
show_choices=False,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
while True:
|
|
343
|
+
if action == "c":
|
|
344
|
+
# Try to copy to clipboard
|
|
345
|
+
try:
|
|
346
|
+
import subprocess
|
|
347
|
+
process = subprocess.Popen(
|
|
348
|
+
["pbcopy"] if sys.platform == "darwin" else ["xclip", "-selection", "clipboard"],
|
|
349
|
+
stdin=subprocess.PIPE
|
|
350
|
+
)
|
|
351
|
+
process.communicate(description.encode())
|
|
352
|
+
click.echo(style_success("Copied to clipboard!"))
|
|
353
|
+
except Exception:
|
|
354
|
+
click.echo(style_warning("Could not copy to clipboard. Copy manually from above."))
|
|
355
|
+
break
|
|
356
|
+
|
|
357
|
+
elif action == "r":
|
|
358
|
+
click.echo()
|
|
359
|
+
click.echo(style_info("Regenerating..."))
|
|
360
|
+
try:
|
|
361
|
+
description = generate_sync(system_prompt, user_prompt)
|
|
362
|
+
description = description.strip()
|
|
363
|
+
except AIError as e:
|
|
364
|
+
click.echo(style_error(str(e)))
|
|
365
|
+
sys.exit(1)
|
|
366
|
+
|
|
367
|
+
click.echo()
|
|
368
|
+
click.echo(style_commit("=" * 60))
|
|
369
|
+
click.echo()
|
|
370
|
+
click.echo(description)
|
|
371
|
+
click.echo()
|
|
372
|
+
click.echo(style_commit("=" * 60))
|
|
373
|
+
click.echo()
|
|
374
|
+
action = click.prompt(
|
|
375
|
+
"Action?",
|
|
376
|
+
type=click.Choice(["c", "r", "q"], case_sensitive=False),
|
|
377
|
+
default="c",
|
|
378
|
+
show_choices=False,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
elif action == "q":
|
|
382
|
+
click.echo(style_info("Done."))
|
|
383
|
+
break
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@main.group()
|
|
387
|
+
def config() -> None:
|
|
388
|
+
"""Manage GitWit configuration."""
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@config.command("set")
|
|
393
|
+
@click.argument("key")
|
|
394
|
+
@click.argument("value")
|
|
395
|
+
def config_set(key: str, value: str) -> None:
|
|
396
|
+
"""Set a configuration value."""
|
|
397
|
+
valid_keys = ["provider", "api-key", "api_key", "model"]
|
|
398
|
+
|
|
399
|
+
if key not in valid_keys:
|
|
400
|
+
click.echo(style_error(f"Unknown key: {key}"))
|
|
401
|
+
click.echo(f"Valid keys: {', '.join(valid_keys)}")
|
|
402
|
+
sys.exit(1)
|
|
403
|
+
|
|
404
|
+
# Validate provider
|
|
405
|
+
if key == "provider":
|
|
406
|
+
valid_providers = ["groq", "gemini", "ollama"]
|
|
407
|
+
if value.lower() not in valid_providers:
|
|
408
|
+
click.echo(style_error(f"Unknown provider: {value}"))
|
|
409
|
+
click.echo(f"Valid providers: {', '.join(valid_providers)}")
|
|
410
|
+
sys.exit(1)
|
|
411
|
+
value = value.lower()
|
|
412
|
+
|
|
413
|
+
set_config_value(key, value)
|
|
414
|
+
click.echo(style_success(f"Set {key} = {value if key != 'api-key' and key != 'api_key' else '***'}"))
|
|
415
|
+
|
|
416
|
+
# Show next steps if provider was set
|
|
417
|
+
if key == "provider" and value != "ollama":
|
|
418
|
+
api_key = get_api_key()
|
|
419
|
+
if not api_key:
|
|
420
|
+
click.echo()
|
|
421
|
+
click.echo(style_info("Next step: Set your API key:"))
|
|
422
|
+
if value == "groq":
|
|
423
|
+
click.echo(" gitwit config set api-key YOUR_GROQ_KEY")
|
|
424
|
+
click.echo(" Get a free key at: https://console.groq.com")
|
|
425
|
+
elif value == "gemini":
|
|
426
|
+
click.echo(" gitwit config set api-key YOUR_GEMINI_KEY")
|
|
427
|
+
click.echo(" Get a free key at: https://aistudio.google.com")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@config.command("show")
|
|
431
|
+
def config_show() -> None:
|
|
432
|
+
"""Show current configuration."""
|
|
433
|
+
cfg = load_config()
|
|
434
|
+
|
|
435
|
+
click.echo()
|
|
436
|
+
click.echo(style_info("GitWit Configuration"))
|
|
437
|
+
click.echo(style_info("=" * 40))
|
|
438
|
+
|
|
439
|
+
provider = cfg.get("provider", "groq")
|
|
440
|
+
click.echo(f"Provider: {provider}")
|
|
441
|
+
|
|
442
|
+
model = cfg.get("model") or DEFAULT_MODELS.get(provider, "")
|
|
443
|
+
click.echo(f"Model: {model}")
|
|
444
|
+
|
|
445
|
+
api_key = cfg.get("api_key", "")
|
|
446
|
+
if api_key:
|
|
447
|
+
masked = api_key[:4] + "..." + api_key[-4:] if len(api_key) > 8 else "***"
|
|
448
|
+
click.echo(f"API Key: {masked}")
|
|
449
|
+
else:
|
|
450
|
+
click.echo(f"API Key: {style_warning('not set')}")
|
|
451
|
+
|
|
452
|
+
click.echo()
|
|
453
|
+
|
|
454
|
+
if not is_configured():
|
|
455
|
+
click.echo(style_warning("Configuration incomplete. Run:"))
|
|
456
|
+
if provider == "ollama":
|
|
457
|
+
click.echo(" Make sure Ollama is running: https://ollama.ai")
|
|
458
|
+
else:
|
|
459
|
+
click.echo(f" gitwit config set api-key YOUR_{provider.upper()}_KEY")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def interactive_setup() -> None:
|
|
463
|
+
"""Run interactive configuration setup."""
|
|
464
|
+
click.echo()
|
|
465
|
+
click.echo(style_info("GitWit Setup"))
|
|
466
|
+
click.echo(style_info("=" * 40))
|
|
467
|
+
click.echo()
|
|
468
|
+
|
|
469
|
+
# Choose provider
|
|
470
|
+
click.echo("Choose your AI provider:")
|
|
471
|
+
click.echo(" 1. Groq (recommended) - 30 req/min free, fastest inference")
|
|
472
|
+
click.echo(" 2. Gemini - 1,500 req/day free")
|
|
473
|
+
click.echo(" 3. Ollama - Unlimited, runs locally")
|
|
474
|
+
click.echo()
|
|
475
|
+
|
|
476
|
+
choice = click.prompt(
|
|
477
|
+
"Provider",
|
|
478
|
+
type=click.Choice(["1", "2", "3"]),
|
|
479
|
+
default="1",
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
provider_map = {"1": "groq", "2": "gemini", "3": "ollama"}
|
|
483
|
+
provider = provider_map[choice]
|
|
484
|
+
set_config_value("provider", provider)
|
|
485
|
+
|
|
486
|
+
# Get API key for cloud providers
|
|
487
|
+
if provider != "ollama":
|
|
488
|
+
click.echo()
|
|
489
|
+
if provider == "groq":
|
|
490
|
+
click.echo("Get your free Groq API key at: https://console.groq.com")
|
|
491
|
+
else:
|
|
492
|
+
click.echo("Get your free Gemini API key at: https://aistudio.google.com")
|
|
493
|
+
|
|
494
|
+
click.echo()
|
|
495
|
+
api_key = click.prompt("API Key", hide_input=True)
|
|
496
|
+
set_config_value("api_key", api_key)
|
|
497
|
+
else:
|
|
498
|
+
click.echo()
|
|
499
|
+
click.echo(style_info("Make sure Ollama is running: https://ollama.ai"))
|
|
500
|
+
click.echo(f"Default model: {DEFAULT_MODELS['ollama']}")
|
|
501
|
+
if click.confirm("Use a different model?", default=False):
|
|
502
|
+
model = click.prompt("Model name")
|
|
503
|
+
set_config_value("model", model)
|
|
504
|
+
|
|
505
|
+
click.echo()
|
|
506
|
+
click.echo(style_success("Configuration complete!"))
|
|
507
|
+
click.echo()
|
|
508
|
+
click.echo("Try it out:")
|
|
509
|
+
click.echo(" git add .")
|
|
510
|
+
click.echo(" gitwit commit")
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@config.command("init")
|
|
514
|
+
def config_init() -> None:
|
|
515
|
+
"""Run interactive configuration setup."""
|
|
516
|
+
interactive_setup()
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
if __name__ == "__main__":
|
|
520
|
+
main()
|
gitwit/config.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Configuration management for GitWit."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
if sys.version_info >= (3, 11):
|
|
9
|
+
import tomllib
|
|
10
|
+
else:
|
|
11
|
+
import tomli as tomllib
|
|
12
|
+
|
|
13
|
+
import tomli_w
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Default configuration
|
|
17
|
+
DEFAULT_CONFIG = {
|
|
18
|
+
"provider": "groq",
|
|
19
|
+
"model": "", # Empty means use provider default
|
|
20
|
+
"api_key": "",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Default models per provider
|
|
24
|
+
DEFAULT_MODELS = {
|
|
25
|
+
"groq": "llama-3.3-70b-versatile",
|
|
26
|
+
"gemini": "gemini-1.5-flash",
|
|
27
|
+
"ollama": "llama3.2",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_config_dir() -> Path:
|
|
32
|
+
"""Get the GitWit configuration directory."""
|
|
33
|
+
return Path.home() / ".gitwit"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_config_path() -> Path:
|
|
37
|
+
"""Get the path to the config file."""
|
|
38
|
+
return get_config_dir() / "config.toml"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ensure_config_dir() -> None:
|
|
42
|
+
"""Ensure the config directory exists."""
|
|
43
|
+
config_dir = get_config_dir()
|
|
44
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_config() -> dict[str, Any]:
|
|
48
|
+
"""
|
|
49
|
+
Load configuration from file.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Configuration dictionary with defaults applied.
|
|
53
|
+
"""
|
|
54
|
+
config_path = get_config_path()
|
|
55
|
+
|
|
56
|
+
if not config_path.exists():
|
|
57
|
+
return DEFAULT_CONFIG.copy()
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
with open(config_path, "rb") as f:
|
|
61
|
+
config = tomllib.load(f)
|
|
62
|
+
except Exception:
|
|
63
|
+
return DEFAULT_CONFIG.copy()
|
|
64
|
+
|
|
65
|
+
# Merge with defaults
|
|
66
|
+
result = DEFAULT_CONFIG.copy()
|
|
67
|
+
result.update(config)
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def save_config(config: dict[str, Any]) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Save configuration to file.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
config: Configuration dictionary to save.
|
|
77
|
+
"""
|
|
78
|
+
ensure_config_dir()
|
|
79
|
+
config_path = get_config_path()
|
|
80
|
+
|
|
81
|
+
with open(config_path, "wb") as f:
|
|
82
|
+
tomli_w.dump(config, f)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_config_value(key: str) -> Any:
|
|
86
|
+
"""
|
|
87
|
+
Get a specific configuration value.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
key: Configuration key (supports dot notation for nested keys).
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The configuration value or None if not found.
|
|
94
|
+
"""
|
|
95
|
+
config = load_config()
|
|
96
|
+
|
|
97
|
+
# Handle dot notation for nested keys
|
|
98
|
+
keys = key.replace("-", "_").split(".")
|
|
99
|
+
value = config
|
|
100
|
+
|
|
101
|
+
for k in keys:
|
|
102
|
+
if isinstance(value, dict):
|
|
103
|
+
value = value.get(k)
|
|
104
|
+
else:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def set_config_value(key: str, value: str) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Set a specific configuration value.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
key: Configuration key (supports dot notation for nested keys).
|
|
116
|
+
value: Value to set.
|
|
117
|
+
"""
|
|
118
|
+
config = load_config()
|
|
119
|
+
|
|
120
|
+
# Normalize key (api-key -> api_key)
|
|
121
|
+
key = key.replace("-", "_")
|
|
122
|
+
|
|
123
|
+
# Handle dot notation for nested keys
|
|
124
|
+
keys = key.split(".")
|
|
125
|
+
|
|
126
|
+
if len(keys) == 1:
|
|
127
|
+
config[keys[0]] = value
|
|
128
|
+
else:
|
|
129
|
+
# Navigate to parent and set value
|
|
130
|
+
current = config
|
|
131
|
+
for k in keys[:-1]:
|
|
132
|
+
if k not in current:
|
|
133
|
+
current[k] = {}
|
|
134
|
+
current = current[k]
|
|
135
|
+
current[keys[-1]] = value
|
|
136
|
+
|
|
137
|
+
save_config(config)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_api_key() -> str | None:
|
|
141
|
+
"""Get the API key for the current provider."""
|
|
142
|
+
return get_config_value("api_key") or os.environ.get("GITWIT_API_KEY")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_provider() -> str:
|
|
146
|
+
"""Get the current AI provider."""
|
|
147
|
+
return get_config_value("provider") or "groq"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_model() -> str:
|
|
151
|
+
"""Get the current model, with provider default fallback."""
|
|
152
|
+
model = get_config_value("model")
|
|
153
|
+
if model:
|
|
154
|
+
return model
|
|
155
|
+
|
|
156
|
+
provider = get_provider()
|
|
157
|
+
return DEFAULT_MODELS.get(provider, "")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def is_configured() -> bool:
|
|
161
|
+
"""Check if GitWit has been configured with necessary credentials."""
|
|
162
|
+
provider = get_provider()
|
|
163
|
+
|
|
164
|
+
# Ollama doesn't need an API key
|
|
165
|
+
if provider == "ollama":
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
api_key = get_api_key()
|
|
169
|
+
return bool(api_key)
|