devgen-cli 0.2.7__tar.gz → 0.2.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {devgen_cli-0.2.7/devgen_cli.egg-info → devgen_cli-0.2.9}/PKG-INFO +1 -2
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/commit.py +1 -1
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/config.py +6 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/setup.py +9 -6
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/changelog_generator.py +9 -5
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/commit_generator.py +17 -26
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/diff_builder.py +5 -4
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/git_ops.py +7 -4
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/gitignore_generator.py +9 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/providers/anthropic.py +3 -1
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/providers/base.py +2 -1
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/providers/gemini.py +1 -1
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/providers/huggingface.py +3 -1
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/providers/ollama.py +13 -9
- devgen_cli-0.2.9/devgen/providers/openai.py +25 -0
- devgen_cli-0.2.9/devgen/providers/openrouter.py +30 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/utils.py +80 -21
- {devgen_cli-0.2.7 → devgen_cli-0.2.9/devgen_cli.egg-info}/PKG-INFO +1 -2
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen_cli.egg-info/requires.txt +0 -1
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/pyproject.toml +1 -2
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/setup.py +1 -1
- devgen_cli-0.2.7/devgen/providers/openai.py +0 -20
- devgen_cli-0.2.7/devgen/providers/openrouter.py +0 -25
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/LICENSE +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/MANIFEST.in +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/README.md +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/__init__.py +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/ai.py +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/__init__.py +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/changelog.py +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/gitignore.py +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/license.py +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/main.py +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/cli/release.py +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/__init__.py +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/changelog_sections.py +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/license_generator.py +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/modules/release_note_generator.py +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/providers/__init__.py +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/commit/commit_message.tpl +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/agpl-3.0.json +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/apache-2.0.json +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/bsd-2-clause.json +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/bsd-3-clause.json +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/bsl-1.0.json +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/cc0-1.0.json +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/epl-2.0.json +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/gpl-2.0.json +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/gpl-3.0.json +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/lgpl-2.1.json +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/mit.json +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/mpl-2.0.json +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen/templates/licenses/unlicense.json +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen_cli.egg-info/SOURCES.txt +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen_cli.egg-info/dependency_links.txt +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen_cli.egg-info/entry_points.txt +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/devgen_cli.egg-info/top_level.txt +0 -0
- {devgen_cli-0.2.7 → devgen_cli-0.2.9}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devgen-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
4
4
|
Summary: A collection of developer tools
|
|
5
5
|
Home-page: https://github.com/S4NKALP/devgen
|
|
6
6
|
Author: Sankalp Tharu
|
|
@@ -22,7 +22,6 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Requires-Dist: anthropic>=0.75.0
|
|
24
24
|
Requires-Dist: google-genai>=0.1.0
|
|
25
|
-
Requires-Dist: jinja2>=3.1.6
|
|
26
25
|
Requires-Dist: openai>=2.11.0
|
|
27
26
|
Requires-Dist: pyyaml>=6.0.3
|
|
28
27
|
Requires-Dist: questionary>=2.1.1
|
|
@@ -48,6 +48,12 @@ def edit_config(
|
|
|
48
48
|
|
|
49
49
|
if not key:
|
|
50
50
|
choices = list(config.keys())
|
|
51
|
+
if not choices:
|
|
52
|
+
typer.secho(
|
|
53
|
+
"No configuration keys found. Run `devgen setup config` first.",
|
|
54
|
+
fg=typer.colors.YELLOW,
|
|
55
|
+
)
|
|
56
|
+
raise typer.Exit()
|
|
51
57
|
key = questionary.select(
|
|
52
58
|
"Select key to update:", choices=choices, style=style
|
|
53
59
|
).ask()
|
|
@@ -85,13 +85,16 @@ def setup_config() -> None:
|
|
|
85
85
|
raise typer.Exit(code=130)
|
|
86
86
|
ollama_host = ollama_host_input.strip() or ollama_host
|
|
87
87
|
|
|
88
|
-
# Save Config
|
|
88
|
+
# Save Config — merge with existing to preserve extra keys
|
|
89
89
|
new_config = {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
**current_config,
|
|
91
|
+
**{
|
|
92
|
+
"provider": provider,
|
|
93
|
+
"model": model,
|
|
94
|
+
"api_key": api_key,
|
|
95
|
+
"emoji": emoji,
|
|
96
|
+
"ollama_host": ollama_host,
|
|
97
|
+
},
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
try:
|
|
@@ -32,17 +32,21 @@ class ChangelogGenerator:
|
|
|
32
32
|
else:
|
|
33
33
|
cmd = self._resolve_range(to_ref)
|
|
34
34
|
try:
|
|
35
|
-
|
|
35
|
+
output = run_git_command(cmd)
|
|
36
|
+
# Use %x00 as separator to avoid splitting on newlines in commit bodies
|
|
37
|
+
# Then filter out empty strings
|
|
38
|
+
return [c for c in output.split("\0") if c.strip()]
|
|
36
39
|
except subprocess.CalledProcessError as e:
|
|
37
40
|
self.logger.error(f"Git command failed: {e}")
|
|
38
41
|
raise RuntimeError(f"Git command failed: {e}") from e
|
|
39
42
|
|
|
40
43
|
def _log_cmd(self, range_spec: str) -> List[str]:
|
|
41
|
-
# Format: hash|author|date|subject|body
|
|
44
|
+
# Format: hash|author|date|subject|body (separated by %x00 to avoid
|
|
45
|
+
# splitting on newlines within commit bodies)
|
|
42
46
|
return [
|
|
43
47
|
"git",
|
|
44
48
|
"log",
|
|
45
|
-
"--format=%H|%an|%ad|%s|%b",
|
|
49
|
+
"--format=%H|%an|%ad|%s|%b%x00",
|
|
46
50
|
"--date=short",
|
|
47
51
|
range_spec,
|
|
48
52
|
]
|
|
@@ -54,7 +58,7 @@ class ChangelogGenerator:
|
|
|
54
58
|
return self._log_cmd(f"{last_tag}..HEAD")
|
|
55
59
|
except (RuntimeError, subprocess.CalledProcessError):
|
|
56
60
|
self.logger.info("No tags found, generating for all commits.")
|
|
57
|
-
return ["git", "log", "--format=%H|%an|%ad|%s|%b", "--date=short"]
|
|
61
|
+
return ["git", "log", "--format=%H|%an|%ad|%s|%b%x00", "--date=short"]
|
|
58
62
|
|
|
59
63
|
# ----------------------------------------------------------------- parsing
|
|
60
64
|
|
|
@@ -145,7 +149,7 @@ class ChangelogGenerator:
|
|
|
145
149
|
old = path.read_text(encoding="utf-8")
|
|
146
150
|
if old.strip().startswith("# CHANGELOG"):
|
|
147
151
|
header, _, rest = old.partition("\n")
|
|
148
|
-
new_content = f"{header}\n\n{md_content}\n{rest
|
|
152
|
+
new_content = f"{header}\n\n{md_content}\n{rest}"
|
|
149
153
|
else:
|
|
150
154
|
new_content = f"# CHANGELOG\n\n{md_content}\n\n{old}"
|
|
151
155
|
else:
|
|
@@ -33,8 +33,8 @@ from devgen.utils import (
|
|
|
33
33
|
extract_commit_messages,
|
|
34
34
|
get_commit_dry_run_path,
|
|
35
35
|
is_file_recent,
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
load_template,
|
|
37
|
+
render_template,
|
|
38
38
|
sanitize_ai_commit_message,
|
|
39
39
|
)
|
|
40
40
|
|
|
@@ -48,6 +48,7 @@ class CacheManager:
|
|
|
48
48
|
|
|
49
49
|
def __init__(self, path: Path, max_age_minutes: int = 120) -> None:
|
|
50
50
|
self.path = path
|
|
51
|
+
self.max_age_minutes = max_age_minutes
|
|
51
52
|
|
|
52
53
|
def init_dry_run(self) -> None:
|
|
53
54
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -59,7 +60,7 @@ class CacheManager:
|
|
|
59
60
|
if dry_run:
|
|
60
61
|
self.init_dry_run()
|
|
61
62
|
return {}
|
|
62
|
-
if not force_rebuild and is_file_recent(self.path):
|
|
63
|
+
if not force_rebuild and is_file_recent(self.path, self.max_age_minutes):
|
|
63
64
|
return extract_commit_messages(self.path)
|
|
64
65
|
return {}
|
|
65
66
|
|
|
@@ -73,13 +74,13 @@ class CommitMessageBuilder:
|
|
|
73
74
|
|
|
74
75
|
def __init__(
|
|
75
76
|
self,
|
|
76
|
-
|
|
77
|
+
template_str: str,
|
|
77
78
|
config: Dict[str, Any],
|
|
78
79
|
provider: str,
|
|
79
80
|
model: str,
|
|
80
81
|
debug: bool = False,
|
|
81
82
|
) -> None:
|
|
82
|
-
self.
|
|
83
|
+
self.template_str = template_str
|
|
83
84
|
self.config = config
|
|
84
85
|
self.provider = provider
|
|
85
86
|
self.model = model
|
|
@@ -96,25 +97,14 @@ class CommitMessageBuilder:
|
|
|
96
97
|
use_emoji = self.config.get("emoji", True)
|
|
97
98
|
ollama_host = self.config.get("ollama_host")
|
|
98
99
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
else:
|
|
108
|
-
prompt = (
|
|
109
|
-
self.template_env.get_template("commit_message.tpl")
|
|
110
|
-
.render(
|
|
111
|
-
group_name=group,
|
|
112
|
-
diff_text=diff,
|
|
113
|
-
use_emoji=use_emoji,
|
|
114
|
-
context=manifest_context,
|
|
115
|
-
)
|
|
116
|
-
.strip()
|
|
117
|
-
)
|
|
100
|
+
template = custom_template or self.template_str
|
|
101
|
+
prompt = render_template(
|
|
102
|
+
template,
|
|
103
|
+
group_name=group,
|
|
104
|
+
diff_text=diff,
|
|
105
|
+
use_emoji=use_emoji,
|
|
106
|
+
context=manifest_context,
|
|
107
|
+
).strip()
|
|
118
108
|
|
|
119
109
|
provider = (
|
|
120
110
|
extra_kwargs.get("provider") or self.config.get("provider") or self.provider
|
|
@@ -197,10 +187,10 @@ class CommitEngine:
|
|
|
197
187
|
),
|
|
198
188
|
logger=self.logger,
|
|
199
189
|
)
|
|
200
|
-
self.
|
|
190
|
+
self.template_str = load_template("commit", "commit_message.tpl")
|
|
201
191
|
self.cache = CacheManager(get_commit_dry_run_path())
|
|
202
192
|
self.message_builder = CommitMessageBuilder(
|
|
203
|
-
self.
|
|
193
|
+
self.template_str,
|
|
204
194
|
self.config,
|
|
205
195
|
provider=self.provider,
|
|
206
196
|
model=self.model,
|
|
@@ -387,6 +377,7 @@ def run_commit_engine(**kwargs: Any) -> None:
|
|
|
387
377
|
sys.exit(0)
|
|
388
378
|
except Exception as e:
|
|
389
379
|
logger.error(f"Commit engine failed: {e}", exc_info=True)
|
|
380
|
+
sys.exit(1)
|
|
390
381
|
|
|
391
382
|
|
|
392
383
|
__all__ = ["CommitEngine", "CommitEngineError", "run_commit_engine"]
|
|
@@ -14,7 +14,7 @@ from collections import defaultdict
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Dict, List, Optional
|
|
16
16
|
|
|
17
|
-
from devgen.utils import run_git_command
|
|
17
|
+
from devgen.utils import get_git_root, run_git_command
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
# Lock files and other metadata blobs whose full diff would just bloat the
|
|
@@ -54,7 +54,7 @@ class DiffBuilder:
|
|
|
54
54
|
logger=None,
|
|
55
55
|
) -> None:
|
|
56
56
|
self.max_size = max_size
|
|
57
|
-
self.cwd = cwd
|
|
57
|
+
self.cwd = cwd or get_git_root()
|
|
58
58
|
self.logger = logger
|
|
59
59
|
|
|
60
60
|
def build(self, files: List[str]) -> str:
|
|
@@ -70,7 +70,7 @@ class DiffBuilder:
|
|
|
70
70
|
def _partition(self, files: List[str]) -> tuple[list[str], list[str]]:
|
|
71
71
|
summary, to_diff = [], []
|
|
72
72
|
for f in files:
|
|
73
|
-
if
|
|
73
|
+
if Path(f).name in IGNORED_DIFF_PATTERNS:
|
|
74
74
|
summary.append(f"[METADATA UPDATED] {f}")
|
|
75
75
|
else:
|
|
76
76
|
to_diff.append(f)
|
|
@@ -128,7 +128,8 @@ class FileGrouper:
|
|
|
128
128
|
deepest = max(candidates, key=lambda p: len(Path(p).parts))
|
|
129
129
|
parent_path = str(Path(deepest).parent)
|
|
130
130
|
new_key = "root" if parent_path == "." else parent_path
|
|
131
|
-
|
|
131
|
+
files = groups.pop(deepest)
|
|
132
|
+
groups.setdefault(new_key, []).extend(files)
|
|
132
133
|
return groups
|
|
133
134
|
|
|
134
135
|
|
|
@@ -10,7 +10,7 @@ import subprocess
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Optional
|
|
12
12
|
|
|
13
|
-
from devgen.utils import configure_logger, run_git_command
|
|
13
|
+
from devgen.utils import configure_logger, get_git_root, run_git_command
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class GitError(RuntimeError):
|
|
@@ -26,7 +26,7 @@ class GitOperator:
|
|
|
26
26
|
logger=None,
|
|
27
27
|
quiet_commands: bool = True,
|
|
28
28
|
) -> None:
|
|
29
|
-
self.cwd = cwd
|
|
29
|
+
self.cwd = cwd or get_git_root()
|
|
30
30
|
self.logger = logger or configure_logger("devgen.git")
|
|
31
31
|
self.quiet_commands = quiet_commands
|
|
32
32
|
|
|
@@ -115,8 +115,11 @@ class GitOperator:
|
|
|
115
115
|
self._run(["git", "add", *existing])
|
|
116
116
|
if deleted:
|
|
117
117
|
self._run(["git", "rm", *deleted])
|
|
118
|
-
except GitError
|
|
119
|
-
|
|
118
|
+
except GitError:
|
|
119
|
+
# Fallback: file state may have changed between check and command.
|
|
120
|
+
# Try a single git add --all for the whole set; git will handle
|
|
121
|
+
# both additions and removals correctly.
|
|
122
|
+
self._run(["git", "add", "--all", "--", *files])
|
|
120
123
|
|
|
121
124
|
def commit(self, message: str) -> None:
|
|
122
125
|
"""``git commit -m`` with the given message."""
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import re
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
from typing import List
|
|
3
4
|
|
|
@@ -5,6 +6,9 @@ import requests
|
|
|
5
6
|
|
|
6
7
|
from devgen.utils import configure_logger
|
|
7
8
|
|
|
9
|
+
# Only allow safe characters in template names to prevent path traversal
|
|
10
|
+
_TEMPLATE_NAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
|
11
|
+
|
|
8
12
|
|
|
9
13
|
class GitignoreGenerator:
|
|
10
14
|
"""Fetches and manages .gitignore templates from GitHub."""
|
|
@@ -41,6 +45,11 @@ class GitignoreGenerator:
|
|
|
41
45
|
self, name: str, use_cache: bool = True, offline: bool = False
|
|
42
46
|
) -> str:
|
|
43
47
|
"""Fetches template content from cache or GitHub."""
|
|
48
|
+
if not _TEMPLATE_NAME_RE.match(name):
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"Invalid template name '{name}'. "
|
|
51
|
+
"Names must contain only alphanumeric characters, hyphens, dots, or underscores."
|
|
52
|
+
)
|
|
44
53
|
cache_file = self.cache_dir / f"{name}.gitignore"
|
|
45
54
|
|
|
46
55
|
if offline:
|
|
@@ -16,4 +16,6 @@ class AnthropicProvider(BaseProvider):
|
|
|
16
16
|
max_tokens=1024,
|
|
17
17
|
messages=[{"role": "user", "content": prompt}],
|
|
18
18
|
)
|
|
19
|
-
|
|
19
|
+
if not message.content:
|
|
20
|
+
raise RuntimeError(f"Anthropic returned no content for model {model!r}.")
|
|
21
|
+
return message.content[0].text or ""
|
|
@@ -53,7 +53,8 @@ class BaseProvider(ABC):
|
|
|
53
53
|
)
|
|
54
54
|
chosen_model = model or self.DEFAULT_MODEL
|
|
55
55
|
try:
|
|
56
|
-
|
|
56
|
+
text = self._generate(prompt, api_key, chosen_model, **kwargs)
|
|
57
|
+
return (text or "").strip()
|
|
57
58
|
except Exception as e:
|
|
58
59
|
self._handle_error(e)
|
|
59
60
|
|
|
@@ -30,7 +30,7 @@ class GeminiProvider(BaseProvider):
|
|
|
30
30
|
max_output_tokens=kwargs.get("max_output_tokens", 2048),
|
|
31
31
|
),
|
|
32
32
|
)
|
|
33
|
-
return response.text
|
|
33
|
+
return response.text or ""
|
|
34
34
|
except Exception as e:
|
|
35
35
|
if is_token_limit_error(e):
|
|
36
36
|
raise # base class handles with friendly message
|
|
@@ -20,7 +20,9 @@ class HuggingfaceProvider(BaseProvider):
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
try:
|
|
23
|
-
response = requests.post(
|
|
23
|
+
response = requests.post(
|
|
24
|
+
api_url, headers=headers, json=payload, timeout=120
|
|
25
|
+
)
|
|
24
26
|
response.raise_for_status()
|
|
25
27
|
result = response.json()
|
|
26
28
|
except requests.HTTPError as e:
|
|
@@ -18,25 +18,29 @@ class OllamaProvider(BaseProvider):
|
|
|
18
18
|
DEFAULT_HOST = "http://localhost:11434"
|
|
19
19
|
DEFAULT_MODEL = "llama3.2"
|
|
20
20
|
|
|
21
|
+
# Keys consumed by devgen itself — never forwarded to the SDK.
|
|
22
|
+
_DEVGEN_KEYS = frozenset({"debug", "ollama_host", "max_retries", "retry_delay"})
|
|
23
|
+
|
|
21
24
|
def _generate(self, prompt, api_key, model, **kwargs):
|
|
22
25
|
host = kwargs.get("ollama_host") or self.DEFAULT_HOST
|
|
23
26
|
base_url = f"{host.rstrip('/')}/v1"
|
|
24
|
-
kwargs.pop("debug", None)
|
|
25
|
-
kwargs.pop("ollama_host", None)
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
# Only forward known OpenAI-compatible parameters
|
|
29
|
+
safe_kwargs = {k: v for k, v in kwargs.items() if k not in self._DEVGEN_KEYS}
|
|
30
|
+
|
|
31
|
+
with OpenAI(base_url=base_url, api_key=api_key or "ollama") as client:
|
|
32
|
+
response = client.chat.completions.create(
|
|
33
|
+
model=model,
|
|
34
|
+
messages=[{"role": "user", "content": prompt}],
|
|
35
|
+
**safe_kwargs,
|
|
36
|
+
)
|
|
33
37
|
|
|
34
38
|
if not response.choices:
|
|
35
39
|
raise RuntimeError(
|
|
36
40
|
f"Ollama returned no choices for model {model!r}. "
|
|
37
41
|
"The model may not be installed locally."
|
|
38
42
|
)
|
|
39
|
-
return response.choices[0].message.content
|
|
43
|
+
return response.choices[0].message.content or ""
|
|
40
44
|
|
|
41
45
|
def _handle_error(self, error):
|
|
42
46
|
"""Override default wrapping — Ollama has no API key to check."""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from openai import OpenAI
|
|
2
|
+
|
|
3
|
+
from devgen.providers.base import BaseProvider
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OpenaiProvider(BaseProvider):
|
|
7
|
+
"""Generates content using OpenAI's Chat Completions API."""
|
|
8
|
+
|
|
9
|
+
DISPLAY_NAME = "OpenAI"
|
|
10
|
+
DEFAULT_MODEL = "gpt-4o"
|
|
11
|
+
|
|
12
|
+
# Keys consumed by devgen itself — never forwarded to the SDK.
|
|
13
|
+
_DEVGEN_KEYS = frozenset({"debug", "ollama_host", "max_retries", "retry_delay"})
|
|
14
|
+
|
|
15
|
+
def _generate(self, prompt, api_key, model, **kwargs):
|
|
16
|
+
safe_kwargs = {k: v for k, v in kwargs.items() if k not in self._DEVGEN_KEYS}
|
|
17
|
+
with OpenAI(api_key=api_key) as client:
|
|
18
|
+
response = client.chat.completions.create(
|
|
19
|
+
model=model,
|
|
20
|
+
messages=[{"role": "user", "content": prompt}],
|
|
21
|
+
**safe_kwargs,
|
|
22
|
+
)
|
|
23
|
+
if not response.choices:
|
|
24
|
+
raise RuntimeError(f"OpenAI returned no choices for model {model!r}.")
|
|
25
|
+
return response.choices[0].message.content or ""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from openai import OpenAI
|
|
2
|
+
|
|
3
|
+
from devgen.providers.base import BaseProvider
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OpenrouterProvider(BaseProvider):
|
|
7
|
+
"""Generates content using OpenRouter (OpenAI-compatible API)."""
|
|
8
|
+
|
|
9
|
+
DISPLAY_NAME = "OpenRouter"
|
|
10
|
+
BASE_URL = "https://openrouter.ai/api/v1"
|
|
11
|
+
DEFAULT_MODEL = "openai/gpt-3.5-turbo"
|
|
12
|
+
|
|
13
|
+
# Keys consumed by devgen itself — never forwarded to the SDK.
|
|
14
|
+
_DEVGEN_KEYS = frozenset({"debug", "ollama_host", "max_retries", "retry_delay"})
|
|
15
|
+
|
|
16
|
+
def _generate(self, prompt, api_key, model, **kwargs):
|
|
17
|
+
safe_kwargs = {k: v for k, v in kwargs.items() if k not in self._DEVGEN_KEYS}
|
|
18
|
+
with OpenAI(base_url=self.BASE_URL, api_key=api_key) as client:
|
|
19
|
+
response = client.chat.completions.create(
|
|
20
|
+
model=model,
|
|
21
|
+
messages=[{"role": "user", "content": prompt}],
|
|
22
|
+
extra_headers={
|
|
23
|
+
"HTTP-Referer": "https://github.com/S4NKALP/devgen",
|
|
24
|
+
"X-Title": "devgen CLI",
|
|
25
|
+
},
|
|
26
|
+
**safe_kwargs,
|
|
27
|
+
)
|
|
28
|
+
if not response.choices:
|
|
29
|
+
raise RuntimeError(f"OpenRouter returned no choices for model {model!r}.")
|
|
30
|
+
return response.choices[0].message.content or ""
|
|
@@ -6,7 +6,6 @@ from pathlib import Path
|
|
|
6
6
|
from typing import Any, Dict, Optional
|
|
7
7
|
|
|
8
8
|
import yaml
|
|
9
|
-
from jinja2 import Environment, FileSystemLoader
|
|
10
9
|
|
|
11
10
|
|
|
12
11
|
def ensure_log_directory() -> Path:
|
|
@@ -24,10 +23,10 @@ def get_commit_dry_run_path() -> Path:
|
|
|
24
23
|
|
|
25
24
|
|
|
26
25
|
def is_file_recent(file_path: Path | str, max_age_minutes: int = 120) -> bool:
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
try:
|
|
27
|
+
return (time.time() - Path(file_path).stat().st_mtime) <= max_age_minutes * 60
|
|
28
|
+
except FileNotFoundError:
|
|
29
29
|
return False
|
|
30
|
-
return (time.time() - path.stat().st_mtime) <= max_age_minutes * 60
|
|
31
30
|
|
|
32
31
|
|
|
33
32
|
def sanitize_ai_commit_message(raw_text: str) -> str:
|
|
@@ -142,6 +141,15 @@ def configure_logger(
|
|
|
142
141
|
return logger
|
|
143
142
|
|
|
144
143
|
|
|
144
|
+
def get_git_root(cwd: Optional[Path] = None) -> Optional[Path]:
|
|
145
|
+
"""Return the root directory of the current git repository, or None."""
|
|
146
|
+
try:
|
|
147
|
+
output = run_git_command(["git", "rev-parse", "--show-toplevel"], cwd=cwd)
|
|
148
|
+
return Path(output) if output else None
|
|
149
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
145
153
|
def run_git_command(
|
|
146
154
|
args: list[str],
|
|
147
155
|
check: bool = True,
|
|
@@ -193,30 +201,74 @@ def get_git_staged_files() -> list[str]:
|
|
|
193
201
|
|
|
194
202
|
|
|
195
203
|
def read_file_content(filepath: Path | str) -> Optional[str]:
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
204
|
+
try:
|
|
205
|
+
return Path(filepath).read_text(encoding="utf-8")
|
|
206
|
+
except FileNotFoundError:
|
|
207
|
+
return None
|
|
200
208
|
|
|
201
209
|
|
|
202
210
|
def delete_file(filepath: Path | str) -> bool:
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
path.unlink()
|
|
211
|
+
try:
|
|
212
|
+
Path(filepath).unlink()
|
|
206
213
|
return True
|
|
207
|
-
|
|
214
|
+
except FileNotFoundError:
|
|
215
|
+
return False
|
|
208
216
|
|
|
209
217
|
|
|
210
|
-
def
|
|
218
|
+
def load_template(sub_dir: str, name: str) -> str:
|
|
219
|
+
"""Load a template file from the templates directory."""
|
|
211
220
|
template_dir = Path(__file__).parent / "templates" / sub_dir
|
|
212
|
-
|
|
221
|
+
template_path = template_dir / name
|
|
222
|
+
if not template_path.is_file():
|
|
223
|
+
raise RuntimeError(
|
|
224
|
+
f"Template not found: {template_path}. The installation may be corrupted."
|
|
225
|
+
)
|
|
226
|
+
return template_path.read_text(encoding="utf-8")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def render_template(template_str: str, **context) -> str:
|
|
230
|
+
"""Render a simple template with variable substitution and conditionals.
|
|
231
|
+
|
|
232
|
+
Supports:
|
|
233
|
+
- {{ variable }} - variable substitution
|
|
234
|
+
- {% if condition %}...{% endif %} - conditional blocks
|
|
235
|
+
- {% if condition %}...{% else %}...{% endif %} - conditional with else
|
|
236
|
+
"""
|
|
237
|
+
result = template_str
|
|
238
|
+
|
|
239
|
+
# Process conditional blocks: {% if var %}...{% endif %}
|
|
240
|
+
# and {% if var %}...{% else %}...{% endif %}
|
|
241
|
+
if_pattern = re.compile(
|
|
242
|
+
r"\{%\s*if\s+(\w+)\s*%\}(.*?)\{%\s*endif\s*%\}",
|
|
243
|
+
re.DOTALL,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def replace_if(match):
|
|
247
|
+
var_name = match.group(1)
|
|
248
|
+
content = match.group(2)
|
|
249
|
+
value = context.get(var_name)
|
|
250
|
+
if value:
|
|
251
|
+
# Check for else block
|
|
252
|
+
else_pattern = re.compile(r"\{%\s*else\s*%\}", re.DOTALL)
|
|
253
|
+
parts = else_pattern.split(content, 1)
|
|
254
|
+
return parts[0]
|
|
255
|
+
else:
|
|
256
|
+
else_pattern = re.compile(r"\{%\s*else\s*%\}", re.DOTALL)
|
|
257
|
+
parts = else_pattern.split(content, 1)
|
|
258
|
+
return parts[1] if len(parts) > 1 else ""
|
|
259
|
+
|
|
260
|
+
result = if_pattern.sub(replace_if, result)
|
|
261
|
+
|
|
262
|
+
# Process variable substitutions: {{ variable }}
|
|
263
|
+
var_pattern = re.compile(r"\{\{\s*(\w+)\s*\}\}")
|
|
264
|
+
|
|
265
|
+
def replace_var(match):
|
|
266
|
+
var_name = match.group(1)
|
|
267
|
+
return str(context.get(var_name, ""))
|
|
213
268
|
|
|
269
|
+
result = var_pattern.sub(replace_var, result)
|
|
214
270
|
|
|
215
|
-
|
|
216
|
-
"""Renders a Jinja2 template from a string."""
|
|
217
|
-
env = Environment()
|
|
218
|
-
template = env.from_string(template_str)
|
|
219
|
-
return template.render(**context)
|
|
271
|
+
return result
|
|
220
272
|
|
|
221
273
|
|
|
222
274
|
def load_config() -> Dict[str, Any]:
|
|
@@ -241,7 +293,13 @@ def load_config() -> Dict[str, Any]:
|
|
|
241
293
|
|
|
242
294
|
try:
|
|
243
295
|
with config_path.open("r", encoding="utf-8") as f:
|
|
244
|
-
|
|
296
|
+
data = yaml.safe_load(f)
|
|
297
|
+
if not isinstance(data, dict):
|
|
298
|
+
print(
|
|
299
|
+
f"Warning: Config at {config_path} is not a dict, using defaults."
|
|
300
|
+
)
|
|
301
|
+
return {}
|
|
302
|
+
return data
|
|
245
303
|
except Exception as e:
|
|
246
304
|
print(f"Warning: Failed to load config from {config_path}: {e}")
|
|
247
305
|
return {}
|
|
@@ -318,7 +376,8 @@ __all__ = [
|
|
|
318
376
|
"get_git_staged_files",
|
|
319
377
|
"read_file_content",
|
|
320
378
|
"delete_file",
|
|
321
|
-
"
|
|
379
|
+
"load_template",
|
|
380
|
+
"render_template",
|
|
322
381
|
"load_config",
|
|
323
382
|
"get_questionary_style",
|
|
324
383
|
"is_token_limit_error",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devgen-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
4
4
|
Summary: A collection of developer tools
|
|
5
5
|
Home-page: https://github.com/S4NKALP/devgen
|
|
6
6
|
Author: Sankalp Tharu
|
|
@@ -22,7 +22,6 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Requires-Dist: anthropic>=0.75.0
|
|
24
24
|
Requires-Dist: google-genai>=0.1.0
|
|
25
|
-
Requires-Dist: jinja2>=3.1.6
|
|
26
25
|
Requires-Dist: openai>=2.11.0
|
|
27
26
|
Requires-Dist: pyyaml>=6.0.3
|
|
28
27
|
Requires-Dist: questionary>=2.1.1
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devgen-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.9"
|
|
8
8
|
description = "A collection of developer tools"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -27,7 +27,6 @@ classifiers = [
|
|
|
27
27
|
dependencies = [
|
|
28
28
|
"anthropic>=0.75.0",
|
|
29
29
|
"google-genai>=0.1.0",
|
|
30
|
-
"jinja2>=3.1.6",
|
|
31
30
|
"openai>=2.11.0",
|
|
32
31
|
"pyyaml>=6.0.3",
|
|
33
32
|
"questionary>=2.1.1",
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
from openai import OpenAI
|
|
2
|
-
|
|
3
|
-
from devgen.providers.base import BaseProvider
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class OpenaiProvider(BaseProvider):
|
|
7
|
-
"""Generates content using OpenAI's Chat Completions API."""
|
|
8
|
-
|
|
9
|
-
DISPLAY_NAME = "OpenAI"
|
|
10
|
-
DEFAULT_MODEL = "gpt-4o"
|
|
11
|
-
|
|
12
|
-
def _generate(self, prompt, api_key, model, **kwargs):
|
|
13
|
-
kwargs.pop("debug", None)
|
|
14
|
-
client = OpenAI(api_key=api_key)
|
|
15
|
-
response = client.chat.completions.create(
|
|
16
|
-
model=model,
|
|
17
|
-
messages=[{"role": "user", "content": prompt}],
|
|
18
|
-
**kwargs,
|
|
19
|
-
)
|
|
20
|
-
return response.choices[0].message.content
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
from openai import OpenAI
|
|
2
|
-
|
|
3
|
-
from devgen.providers.base import BaseProvider
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class OpenrouterProvider(BaseProvider):
|
|
7
|
-
"""Generates content using OpenRouter (OpenAI-compatible API)."""
|
|
8
|
-
|
|
9
|
-
DISPLAY_NAME = "OpenRouter"
|
|
10
|
-
BASE_URL = "https://openrouter.ai/api/v1"
|
|
11
|
-
DEFAULT_MODEL = "openai/gpt-3.5-turbo"
|
|
12
|
-
|
|
13
|
-
def _generate(self, prompt, api_key, model, **kwargs):
|
|
14
|
-
kwargs.pop("debug", None)
|
|
15
|
-
client = OpenAI(base_url=self.BASE_URL, api_key=api_key)
|
|
16
|
-
response = client.chat.completions.create(
|
|
17
|
-
model=model,
|
|
18
|
-
messages=[{"role": "user", "content": prompt}],
|
|
19
|
-
extra_headers={
|
|
20
|
-
"HTTP-Referer": "https://github.com/S4NKALP/devgen",
|
|
21
|
-
"X-Title": "devgen CLI",
|
|
22
|
-
},
|
|
23
|
-
**kwargs,
|
|
24
|
-
)
|
|
25
|
-
return response.choices[0].message.content
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|