prgen-cli 0.2.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.
- prgen/__main__.py +4 -0
- prgen/about.py +16 -0
- prgen/api_errors.py +81 -0
- prgen/cli.py +518 -0
- prgen/config.py +203 -0
- prgen/defaults.py +24 -0
- prgen/git_utils.py +74 -0
- prgen/prompting.py +23 -0
- prgen/prompts/pr_description.txt +64 -0
- prgen/providers/gemini_provider.py +29 -0
- prgen/providers/openai_provider.py +29 -0
- prgen/ui.py +87 -0
- prgen_cli-0.2.1.dist-info/METADATA +151 -0
- prgen_cli-0.2.1.dist-info/RECORD +18 -0
- prgen_cli-0.2.1.dist-info/WHEEL +5 -0
- prgen_cli-0.2.1.dist-info/entry_points.txt +2 -0
- prgen_cli-0.2.1.dist-info/licenses/LICENSE +691 -0
- prgen_cli-0.2.1.dist-info/top_level.txt +1 -0
prgen/__main__.py
ADDED
prgen/about.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Project attribution (shown in CLI and Rich output)."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
AUTHOR = "Jean Paul Fernandez"
|
|
6
|
+
REPO_URL = "https://github.com/jpxoi/prgen"
|
|
7
|
+
|
|
8
|
+
# PyPI distribution name (import package remains `prgen`).
|
|
9
|
+
_DISTRIBUTION_NAME = "prgen-cli"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def package_version() -> str:
|
|
13
|
+
try:
|
|
14
|
+
return version(_DISTRIBUTION_NAME)
|
|
15
|
+
except PackageNotFoundError:
|
|
16
|
+
return "0.0.0"
|
prgen/api_errors.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""User-facing messages for LLM provider failures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
Backend = Literal["openai", "gemini"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _trim(msg: str, max_len: int = 220) -> str:
|
|
11
|
+
one = " ".join(msg.split())
|
|
12
|
+
if len(one) <= max_len:
|
|
13
|
+
return one
|
|
14
|
+
return one[: max_len - 1] + "…"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_provider_error(exc: BaseException, *, backend: Backend) -> str:
|
|
18
|
+
"""Map SDK exceptions to a short stderr-safe line (no traceback)."""
|
|
19
|
+
if backend == "openai":
|
|
20
|
+
return _openai_user_message(exc)
|
|
21
|
+
return _gemini_user_message(exc)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _openai_user_message(exc: BaseException) -> str:
|
|
25
|
+
try:
|
|
26
|
+
import openai
|
|
27
|
+
except ImportError:
|
|
28
|
+
return f"OpenAI: {_trim(str(exc))}"
|
|
29
|
+
|
|
30
|
+
if isinstance(exc, openai.AuthenticationError):
|
|
31
|
+
return "OpenAI: invalid or revoked API key — check OPENAI_API_KEY."
|
|
32
|
+
if isinstance(exc, openai.PermissionDeniedError):
|
|
33
|
+
return "OpenAI: permission denied — account or model may not be allowed."
|
|
34
|
+
if isinstance(exc, openai.RateLimitError):
|
|
35
|
+
return "OpenAI: rate limited — wait and retry, or check your plan."
|
|
36
|
+
if isinstance(exc, (openai.APIConnectionError, openai.APITimeoutError)):
|
|
37
|
+
return "OpenAI: network error — check your connection and try again."
|
|
38
|
+
if isinstance(exc, openai.NotFoundError):
|
|
39
|
+
return "OpenAI: model not found — check --model / --tier."
|
|
40
|
+
if isinstance(exc, openai.BadRequestError):
|
|
41
|
+
return f"OpenAI: bad request — {_trim(exc.message)}"
|
|
42
|
+
if isinstance(exc, openai.APIStatusError):
|
|
43
|
+
sc = exc.status_code
|
|
44
|
+
if sc == 401:
|
|
45
|
+
return "OpenAI: unauthorized — check your API key."
|
|
46
|
+
if sc == 403:
|
|
47
|
+
return "OpenAI: forbidden — model or org may not allow this request."
|
|
48
|
+
if sc == 404:
|
|
49
|
+
return "OpenAI: not found — model or endpoint unavailable."
|
|
50
|
+
if sc == 429:
|
|
51
|
+
return "OpenAI: too many requests — retry after a short wait."
|
|
52
|
+
if sc >= 500:
|
|
53
|
+
return "OpenAI: server error — try again later."
|
|
54
|
+
return f"OpenAI: HTTP {sc} — {_trim(exc.message)}"
|
|
55
|
+
if isinstance(exc, openai.APIError):
|
|
56
|
+
return f"OpenAI: {_trim(exc.message)}"
|
|
57
|
+
return f"OpenAI: {_trim(str(exc))}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _gemini_user_message(exc: BaseException) -> str:
|
|
61
|
+
try:
|
|
62
|
+
from google.genai import errors as genai_errors
|
|
63
|
+
except ImportError:
|
|
64
|
+
return f"Gemini: {_trim(str(exc))}"
|
|
65
|
+
|
|
66
|
+
if isinstance(exc, genai_errors.ClientError):
|
|
67
|
+
code = getattr(exc, "code", None)
|
|
68
|
+
if code in (401, 403):
|
|
69
|
+
return "Gemini: API key invalid or access denied — check GOOGLE_API_KEY."
|
|
70
|
+
if code == 404:
|
|
71
|
+
return "Gemini: model or resource not found — check --model / --tier."
|
|
72
|
+
if code == 429:
|
|
73
|
+
return "Gemini: rate limited — wait and retry."
|
|
74
|
+
if code is not None and 400 <= int(code) < 500:
|
|
75
|
+
return f"Gemini: request failed (HTTP {code}). {_trim(str(exc))}"
|
|
76
|
+
if isinstance(exc, genai_errors.ServerError):
|
|
77
|
+
return "Gemini: server error — try again later."
|
|
78
|
+
if isinstance(exc, genai_errors.APIError):
|
|
79
|
+
code = getattr(exc, "code", "?")
|
|
80
|
+
return f"Gemini: API error (HTTP {code}) — {_trim(str(exc))}"
|
|
81
|
+
return f"Gemini: {_trim(str(exc))}"
|
prgen/cli.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, cast
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from click.core import ParameterSource
|
|
11
|
+
from rich import box
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.prompt import Prompt
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from prgen.about import AUTHOR, REPO_URL, package_version
|
|
19
|
+
from prgen.api_errors import Backend, format_provider_error
|
|
20
|
+
from prgen.config import (
|
|
21
|
+
PersistedConfigKey,
|
|
22
|
+
load_prgen_env,
|
|
23
|
+
public_config_rows,
|
|
24
|
+
read_cli_defaults_from_config,
|
|
25
|
+
read_persisted_config,
|
|
26
|
+
set_user_key,
|
|
27
|
+
unset_user_key,
|
|
28
|
+
user_config_path,
|
|
29
|
+
)
|
|
30
|
+
from prgen.defaults import ModelTier, ProviderChoice, model_for_tier
|
|
31
|
+
from prgen.git_utils import (
|
|
32
|
+
GitNotFoundError,
|
|
33
|
+
GitRefError,
|
|
34
|
+
ensure_git_available,
|
|
35
|
+
get_commits,
|
|
36
|
+
get_diff,
|
|
37
|
+
git_repo_root,
|
|
38
|
+
verify_base_ref,
|
|
39
|
+
)
|
|
40
|
+
from prgen.prompting import build_prompt, load_prompt_template
|
|
41
|
+
from prgen.ui import loading, print_pr_summary
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _version_callback(value: bool) -> None:
|
|
45
|
+
if value:
|
|
46
|
+
typer.echo(f"prgen {package_version()}")
|
|
47
|
+
typer.echo(f"Author: {AUTHOR}")
|
|
48
|
+
typer.echo(REPO_URL)
|
|
49
|
+
raise typer.Exit(0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _api_key_setup_hint() -> str:
|
|
53
|
+
return (
|
|
54
|
+
"Run: prgen config\n"
|
|
55
|
+
"Keys live in ~/.config/prgen/config.json (prgen config set …, or edit that file)."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def extract_tag(text: str, tag: str) -> str | None:
|
|
60
|
+
match = re.search(rf"<{tag}>\s*(.*?)\s*</{tag}>", text, re.DOTALL | re.IGNORECASE)
|
|
61
|
+
return match.group(1).strip() if match else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _print_config_table(console: Console) -> None:
|
|
65
|
+
rows = public_config_rows()
|
|
66
|
+
path = user_config_path()
|
|
67
|
+
console.print(f"[dim]{path}[/dim]\n")
|
|
68
|
+
table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))
|
|
69
|
+
table.add_column("Key", style="bold")
|
|
70
|
+
table.add_column("Value")
|
|
71
|
+
for key, val, is_secret in rows:
|
|
72
|
+
if is_secret:
|
|
73
|
+
value_cell = (
|
|
74
|
+
Text("set", style="green bold") if val == "set" else Text("unset", style="yellow")
|
|
75
|
+
)
|
|
76
|
+
elif val == "unset":
|
|
77
|
+
value_cell = Text("unset", style="dim")
|
|
78
|
+
else:
|
|
79
|
+
value_cell = Text(val, style="cyan")
|
|
80
|
+
table.add_row(key, value_cell)
|
|
81
|
+
console.print(table)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_interactive_config() -> int:
|
|
85
|
+
"""Interactive wizard for ~/.config/prgen/config.json (all persisted keys)."""
|
|
86
|
+
if not sys.stdin.isatty():
|
|
87
|
+
print(
|
|
88
|
+
"prgen: `config` needs an interactive terminal (stdin is not a TTY).",
|
|
89
|
+
file=sys.stderr,
|
|
90
|
+
)
|
|
91
|
+
return 1
|
|
92
|
+
|
|
93
|
+
console = Console(stderr=True)
|
|
94
|
+
try:
|
|
95
|
+
data = read_persisted_config()
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
print(f"prgen: {exc}", file=sys.stderr)
|
|
98
|
+
return 1
|
|
99
|
+
|
|
100
|
+
console.print(
|
|
101
|
+
Panel.fit(
|
|
102
|
+
"[bold]Interactive config[/bold]\n"
|
|
103
|
+
f"[dim]prgen v{package_version()} · {AUTHOR}[/dim]\n\n"
|
|
104
|
+
"[dim]• API keys: hidden; empty = leave unchanged.\n"
|
|
105
|
+
"• Any field: type [bold]-[/bold] to remove the saved value.\n"
|
|
106
|
+
"• Defaults: Enter accepts the suggested default.\n"
|
|
107
|
+
"• Omit saving implicit defaults (auto / default) unless you change them.[/dim]",
|
|
108
|
+
title="prgen",
|
|
109
|
+
border_style="dim",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
console.print()
|
|
113
|
+
|
|
114
|
+
updates: dict[str, str] = {}
|
|
115
|
+
unsets: list[str] = []
|
|
116
|
+
|
|
117
|
+
console.print("[bold]API keys[/bold]\n")
|
|
118
|
+
for key, label in (
|
|
119
|
+
("OPENAI_API_KEY", "OpenAI"),
|
|
120
|
+
("GOOGLE_API_KEY", "Google AI / Gemini"),
|
|
121
|
+
):
|
|
122
|
+
hint = " [dim](saved)[/dim]" if (data.get(key) or "").strip() else ""
|
|
123
|
+
try:
|
|
124
|
+
line = Prompt.ask(
|
|
125
|
+
f"{label}{hint}",
|
|
126
|
+
password=True,
|
|
127
|
+
)
|
|
128
|
+
except (EOFError, KeyboardInterrupt):
|
|
129
|
+
console.print("\n[yellow]Aborted.[/yellow]")
|
|
130
|
+
return 1
|
|
131
|
+
line = (line or "").strip()
|
|
132
|
+
if line == "-":
|
|
133
|
+
if (data.get(key) or "").strip():
|
|
134
|
+
unsets.append(key)
|
|
135
|
+
elif line:
|
|
136
|
+
updates[key] = line
|
|
137
|
+
|
|
138
|
+
console.print("\n[bold]CLI defaults[/bold]\n")
|
|
139
|
+
|
|
140
|
+
cur_base = (data.get("base") or "").strip()
|
|
141
|
+
try:
|
|
142
|
+
raw_base = Prompt.ask(
|
|
143
|
+
"Merge base ref (git)",
|
|
144
|
+
default=cur_base,
|
|
145
|
+
).strip()
|
|
146
|
+
except (EOFError, KeyboardInterrupt):
|
|
147
|
+
console.print("\n[yellow]Aborted.[/yellow]")
|
|
148
|
+
return 1
|
|
149
|
+
if raw_base == "-":
|
|
150
|
+
if cur_base:
|
|
151
|
+
unsets.append("base")
|
|
152
|
+
elif raw_base != cur_base:
|
|
153
|
+
if raw_base:
|
|
154
|
+
updates["base"] = raw_base
|
|
155
|
+
|
|
156
|
+
cur_p = (data.get("provider") or "").strip()
|
|
157
|
+
if cur_p not in ("auto", "openai", "gemini"):
|
|
158
|
+
cur_p = ""
|
|
159
|
+
prompt_p = cur_p if cur_p else "auto"
|
|
160
|
+
while True:
|
|
161
|
+
try:
|
|
162
|
+
raw_p = Prompt.ask(
|
|
163
|
+
"Provider [auto|openai|gemini]",
|
|
164
|
+
default=prompt_p,
|
|
165
|
+
).strip()
|
|
166
|
+
except (EOFError, KeyboardInterrupt):
|
|
167
|
+
console.print("\n[yellow]Aborted.[/yellow]")
|
|
168
|
+
return 1
|
|
169
|
+
if raw_p == "-":
|
|
170
|
+
if cur_p:
|
|
171
|
+
unsets.append("provider")
|
|
172
|
+
break
|
|
173
|
+
if raw_p in ("auto", "openai", "gemini"):
|
|
174
|
+
if raw_p == cur_p:
|
|
175
|
+
pass
|
|
176
|
+
elif not cur_p and raw_p == "auto":
|
|
177
|
+
pass
|
|
178
|
+
else:
|
|
179
|
+
updates["provider"] = raw_p
|
|
180
|
+
break
|
|
181
|
+
console.print("[red]Invalid: use auto, openai, gemini, or -[/red]")
|
|
182
|
+
|
|
183
|
+
cur_t = (data.get("tier") or "").strip()
|
|
184
|
+
if cur_t not in ("default", "pro"):
|
|
185
|
+
cur_t = ""
|
|
186
|
+
prompt_t = cur_t if cur_t else "default"
|
|
187
|
+
while True:
|
|
188
|
+
try:
|
|
189
|
+
raw_t = Prompt.ask(
|
|
190
|
+
"Model tier [default|pro]",
|
|
191
|
+
default=prompt_t,
|
|
192
|
+
).strip()
|
|
193
|
+
except (EOFError, KeyboardInterrupt):
|
|
194
|
+
console.print("\n[yellow]Aborted.[/yellow]")
|
|
195
|
+
return 1
|
|
196
|
+
if raw_t == "-":
|
|
197
|
+
if cur_t:
|
|
198
|
+
unsets.append("tier")
|
|
199
|
+
break
|
|
200
|
+
if raw_t in ("default", "pro"):
|
|
201
|
+
if raw_t == cur_t:
|
|
202
|
+
pass
|
|
203
|
+
elif not cur_t and raw_t == "default":
|
|
204
|
+
pass
|
|
205
|
+
else:
|
|
206
|
+
updates["tier"] = raw_t
|
|
207
|
+
break
|
|
208
|
+
console.print("[red]Invalid: use default, pro, or -[/red]")
|
|
209
|
+
|
|
210
|
+
if not unsets and not updates:
|
|
211
|
+
console.print("\n[dim]No changes.[/dim]")
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
for key in dict.fromkeys(unsets):
|
|
216
|
+
unset_user_key(key)
|
|
217
|
+
for key, value in updates.items():
|
|
218
|
+
set_user_key(key, value)
|
|
219
|
+
except ValueError as exc:
|
|
220
|
+
print(f"prgen: {exc}", file=sys.stderr)
|
|
221
|
+
return 1
|
|
222
|
+
|
|
223
|
+
console.print("\n[green]Saved.[/green]\n")
|
|
224
|
+
_print_config_table(console)
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def resolve_credentials(provider: ProviderChoice) -> tuple[str, str]:
|
|
229
|
+
"""Return (backend, api_key). Raises ValueError if misconfigured."""
|
|
230
|
+
openai_key = os.getenv("OPENAI_API_KEY")
|
|
231
|
+
google_key = os.getenv("GOOGLE_API_KEY")
|
|
232
|
+
|
|
233
|
+
if provider == "openai":
|
|
234
|
+
if not openai_key:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
"Missing OPENAI_API_KEY (--provider openai).\n\n" + _api_key_setup_hint()
|
|
237
|
+
)
|
|
238
|
+
return "openai", openai_key
|
|
239
|
+
if provider == "gemini":
|
|
240
|
+
if not google_key:
|
|
241
|
+
raise ValueError(
|
|
242
|
+
"Missing GOOGLE_API_KEY (--provider gemini).\n\n" + _api_key_setup_hint()
|
|
243
|
+
)
|
|
244
|
+
return "gemini", google_key
|
|
245
|
+
|
|
246
|
+
if google_key:
|
|
247
|
+
return "gemini", google_key
|
|
248
|
+
if openai_key:
|
|
249
|
+
return "openai", openai_key
|
|
250
|
+
raise ValueError("No API keys configured.\n\n" + _api_key_setup_hint())
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def run_summarize(
|
|
254
|
+
*,
|
|
255
|
+
repo: Path | None,
|
|
256
|
+
base: str,
|
|
257
|
+
provider: ProviderChoice,
|
|
258
|
+
tier: ModelTier,
|
|
259
|
+
model: str | None,
|
|
260
|
+
context: str,
|
|
261
|
+
) -> int:
|
|
262
|
+
try:
|
|
263
|
+
ensure_git_available()
|
|
264
|
+
except GitNotFoundError as exc:
|
|
265
|
+
print(f"prgen: {exc}", file=sys.stderr)
|
|
266
|
+
return 1
|
|
267
|
+
|
|
268
|
+
git_cwd: Path | None = None
|
|
269
|
+
if repo is not None:
|
|
270
|
+
try:
|
|
271
|
+
git_cwd = git_repo_root(repo)
|
|
272
|
+
except ValueError as exc:
|
|
273
|
+
print(str(exc), file=sys.stderr)
|
|
274
|
+
return 1
|
|
275
|
+
try:
|
|
276
|
+
load_prgen_env()
|
|
277
|
+
except ValueError as exc:
|
|
278
|
+
print(f"prgen: {exc}", file=sys.stderr)
|
|
279
|
+
return 1
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
backend, api_key = resolve_credentials(provider)
|
|
283
|
+
except ValueError as exc:
|
|
284
|
+
print(f"prgen: {exc}", file=sys.stderr)
|
|
285
|
+
return 1
|
|
286
|
+
|
|
287
|
+
if model is not None:
|
|
288
|
+
model_id = model
|
|
289
|
+
else:
|
|
290
|
+
model_id = model_for_tier(
|
|
291
|
+
"openai" if backend == "openai" else "gemini",
|
|
292
|
+
tier,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
try:
|
|
297
|
+
verify_base_ref(base, cwd=git_cwd)
|
|
298
|
+
except GitRefError as exc:
|
|
299
|
+
print(f"prgen: {exc}", file=sys.stderr)
|
|
300
|
+
return 1
|
|
301
|
+
|
|
302
|
+
diff = get_diff(base, cwd=git_cwd)
|
|
303
|
+
commits = get_commits(base, cwd=git_cwd)
|
|
304
|
+
|
|
305
|
+
if not diff and not commits:
|
|
306
|
+
print(
|
|
307
|
+
f"No commits or file changes vs {base}. Adjust --base or make changes.",
|
|
308
|
+
file=sys.stderr,
|
|
309
|
+
)
|
|
310
|
+
return 1
|
|
311
|
+
|
|
312
|
+
template = load_prompt_template()
|
|
313
|
+
prompt = build_prompt(
|
|
314
|
+
template=template,
|
|
315
|
+
diff=diff,
|
|
316
|
+
commits=commits,
|
|
317
|
+
additional_context=context,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
with loading(model=model_id):
|
|
322
|
+
if backend == "openai":
|
|
323
|
+
from prgen.providers.openai_provider import OpenAIProvider
|
|
324
|
+
|
|
325
|
+
output = OpenAIProvider(api_key=api_key, model=model_id).generate(prompt)
|
|
326
|
+
else:
|
|
327
|
+
from prgen.providers.gemini_provider import GeminiProvider
|
|
328
|
+
|
|
329
|
+
output = GeminiProvider(api_key=api_key, model=model_id).generate(prompt)
|
|
330
|
+
except ImportError as exc:
|
|
331
|
+
print(f"prgen: {exc}", file=sys.stderr)
|
|
332
|
+
return 1
|
|
333
|
+
except Exception as exc:
|
|
334
|
+
print(
|
|
335
|
+
f"prgen: {format_provider_error(exc, backend=cast(Backend, backend))}",
|
|
336
|
+
file=sys.stderr,
|
|
337
|
+
)
|
|
338
|
+
return 1
|
|
339
|
+
|
|
340
|
+
summary = extract_tag(output, "summary")
|
|
341
|
+
body = extract_tag(output, "body")
|
|
342
|
+
|
|
343
|
+
has_parts = bool(summary and body)
|
|
344
|
+
print_pr_summary(
|
|
345
|
+
summary if has_parts else None,
|
|
346
|
+
body if has_parts else None,
|
|
347
|
+
output,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
return 0
|
|
351
|
+
|
|
352
|
+
except Exception as exc:
|
|
353
|
+
print(f"prgen: {exc}", file=sys.stderr)
|
|
354
|
+
return 1
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
_CREDIT_EPILOG = f"[dim]{AUTHOR} · {REPO_URL}[/dim]"
|
|
358
|
+
|
|
359
|
+
app = typer.Typer(
|
|
360
|
+
name="prgen",
|
|
361
|
+
help=(
|
|
362
|
+
"PR title and body from git diff and commits vs --base. "
|
|
363
|
+
"Optional defaults for --base, --provider, and --tier: ~/.config/prgen/config.json."
|
|
364
|
+
),
|
|
365
|
+
invoke_without_command=True,
|
|
366
|
+
no_args_is_help=False,
|
|
367
|
+
epilog=_CREDIT_EPILOG,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
config_app = typer.Typer(
|
|
371
|
+
name="config",
|
|
372
|
+
help="Configure ~/.config/prgen/config.json (interactive when no subcommand).",
|
|
373
|
+
invoke_without_command=True,
|
|
374
|
+
no_args_is_help=False,
|
|
375
|
+
epilog=_CREDIT_EPILOG,
|
|
376
|
+
)
|
|
377
|
+
app.add_typer(config_app, name="config")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@app.callback()
|
|
381
|
+
def _root(
|
|
382
|
+
ctx: typer.Context,
|
|
383
|
+
_version: Annotated[
|
|
384
|
+
bool,
|
|
385
|
+
typer.Option(
|
|
386
|
+
"--version",
|
|
387
|
+
callback=_version_callback,
|
|
388
|
+
is_eager=True,
|
|
389
|
+
help="Print version.",
|
|
390
|
+
),
|
|
391
|
+
] = False,
|
|
392
|
+
repo: Annotated[
|
|
393
|
+
Path | None,
|
|
394
|
+
typer.Option(
|
|
395
|
+
"-C",
|
|
396
|
+
"--repo",
|
|
397
|
+
metavar="PATH",
|
|
398
|
+
help="Git working directory (git -C).",
|
|
399
|
+
),
|
|
400
|
+
] = None,
|
|
401
|
+
base: Annotated[
|
|
402
|
+
str,
|
|
403
|
+
typer.Option(
|
|
404
|
+
"--base",
|
|
405
|
+
help="Merge base ref for diff and log vs HEAD.",
|
|
406
|
+
),
|
|
407
|
+
] = "origin/main",
|
|
408
|
+
provider: Annotated[
|
|
409
|
+
ProviderChoice,
|
|
410
|
+
typer.Option(
|
|
411
|
+
"--provider",
|
|
412
|
+
help="auto picks Gemini when both API keys exist in config.",
|
|
413
|
+
),
|
|
414
|
+
] = "auto",
|
|
415
|
+
tier: Annotated[
|
|
416
|
+
ModelTier,
|
|
417
|
+
typer.Option(
|
|
418
|
+
"--tier",
|
|
419
|
+
help="Cheaper/faster vs stronger models.",
|
|
420
|
+
),
|
|
421
|
+
] = "default",
|
|
422
|
+
model: Annotated[
|
|
423
|
+
str | None,
|
|
424
|
+
typer.Option(
|
|
425
|
+
"--model",
|
|
426
|
+
metavar="ID",
|
|
427
|
+
help="Overrides --tier preset.",
|
|
428
|
+
),
|
|
429
|
+
] = None,
|
|
430
|
+
context: Annotated[
|
|
431
|
+
str,
|
|
432
|
+
typer.Option(
|
|
433
|
+
"--context",
|
|
434
|
+
metavar="TEXT",
|
|
435
|
+
help="Extra text merged into the prompt.",
|
|
436
|
+
),
|
|
437
|
+
] = "none",
|
|
438
|
+
) -> None:
|
|
439
|
+
if ctx.invoked_subcommand is not None:
|
|
440
|
+
return
|
|
441
|
+
cfg_base, cfg_provider, cfg_tier = read_cli_defaults_from_config()
|
|
442
|
+
if ctx.get_parameter_source("base") == ParameterSource.DEFAULT and cfg_base is not None:
|
|
443
|
+
base = cfg_base
|
|
444
|
+
if ctx.get_parameter_source("provider") == ParameterSource.DEFAULT and cfg_provider is not None:
|
|
445
|
+
provider = cfg_provider
|
|
446
|
+
if ctx.get_parameter_source("tier") == ParameterSource.DEFAULT and cfg_tier is not None:
|
|
447
|
+
tier = cfg_tier
|
|
448
|
+
raise typer.Exit(
|
|
449
|
+
run_summarize(
|
|
450
|
+
repo=repo,
|
|
451
|
+
base=base,
|
|
452
|
+
provider=provider,
|
|
453
|
+
tier=tier,
|
|
454
|
+
model=model,
|
|
455
|
+
context=context,
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@config_app.callback()
|
|
461
|
+
def _config_entry(ctx: typer.Context) -> None:
|
|
462
|
+
if ctx.invoked_subcommand is not None:
|
|
463
|
+
return
|
|
464
|
+
raise typer.Exit(run_interactive_config())
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@config_app.command("set", help="Set KEY to VALUE in config JSON.")
|
|
468
|
+
def _config_set(
|
|
469
|
+
key: Annotated[PersistedConfigKey, typer.Argument(help="Field name.")],
|
|
470
|
+
value: Annotated[str, typer.Argument(help="Value; use - for stdin")],
|
|
471
|
+
) -> None:
|
|
472
|
+
try:
|
|
473
|
+
val = value
|
|
474
|
+
if val == "-":
|
|
475
|
+
val = sys.stdin.read()
|
|
476
|
+
val = val.strip("\n\r")
|
|
477
|
+
path = set_user_key(key, val)
|
|
478
|
+
print(f"Wrote {key} to {path}", file=sys.stderr)
|
|
479
|
+
except ValueError as exc:
|
|
480
|
+
print(f"prgen: {exc}", file=sys.stderr)
|
|
481
|
+
raise typer.Exit(1) from exc
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@config_app.command("unset", help="Remove KEY from config JSON.")
|
|
485
|
+
def _config_unset(
|
|
486
|
+
key: Annotated[PersistedConfigKey, typer.Argument(help="Field name.")],
|
|
487
|
+
) -> None:
|
|
488
|
+
try:
|
|
489
|
+
path = unset_user_key(key)
|
|
490
|
+
print(f"Removed {key} from {path}", file=sys.stderr)
|
|
491
|
+
except ValueError as exc:
|
|
492
|
+
print(f"prgen: {exc}", file=sys.stderr)
|
|
493
|
+
raise typer.Exit(1) from exc
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@config_app.command(
|
|
497
|
+
"show",
|
|
498
|
+
help="Print config.json (non-secret values; API keys as set or unset only).",
|
|
499
|
+
)
|
|
500
|
+
def _config_show() -> None:
|
|
501
|
+
try:
|
|
502
|
+
_print_config_table(Console())
|
|
503
|
+
except ValueError as exc:
|
|
504
|
+
print(f"prgen: {exc}", file=sys.stderr)
|
|
505
|
+
raise typer.Exit(1) from exc
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
@config_app.command("path", help="Print config file path.")
|
|
509
|
+
def _config_path() -> None:
|
|
510
|
+
typer.echo(user_config_path())
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def main() -> None:
|
|
514
|
+
app()
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
if __name__ == "__main__":
|
|
518
|
+
main()
|