siftcode 0.3.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.
- siftcode/__init__.py +3 -0
- siftcode/__main__.py +11 -0
- siftcode/_version.py +59 -0
- siftcode/cli.py +531 -0
- siftcode/core/__init__.py +1 -0
- siftcode/core/analyzer.py +575 -0
- siftcode/core/cache.py +300 -0
- siftcode/core/config.py +258 -0
- siftcode/core/llm.py +295 -0
- siftcode/core/manual.py +208 -0
- siftcode/core/prompts.py +215 -0
- siftcode/data/__init__.py +0 -0
- siftcode/data/siftcode-logo.png +0 -0
- siftcode/gui/__init__.py +9 -0
- siftcode/gui/analyzer_view.py +560 -0
- siftcode/gui/app.py +252 -0
- siftcode/gui/guide_window.py +88 -0
- siftcode/gui/project_list.py +160 -0
- siftcode/gui/settings_view.py +430 -0
- siftcode/gui/widgets/__init__.py +1 -0
- siftcode/gui/widgets/exclusion_list.py +169 -0
- siftcode/gui/widgets/log_view.py +108 -0
- siftcode/gui/widgets/markdown_view.py +205 -0
- siftcode/gui/widgets/project_row.py +141 -0
- siftcode/gui/window.py +446 -0
- siftcode-0.3.0.dist-info/METADATA +413 -0
- siftcode-0.3.0.dist-info/RECORD +31 -0
- siftcode-0.3.0.dist-info/WHEEL +5 -0
- siftcode-0.3.0.dist-info/entry_points.txt +5 -0
- siftcode-0.3.0.dist-info/licenses/LICENSE +674 -0
- siftcode-0.3.0.dist-info/top_level.txt +1 -0
siftcode/__init__.py
ADDED
siftcode/__main__.py
ADDED
siftcode/_version.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Central metadata for Siftcode.
|
|
3
|
+
|
|
4
|
+
This module is the single source of truth for the application's
|
|
5
|
+
version, name, author, license, and other identifying information.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__version__ = "0.3.0"
|
|
11
|
+
__version_info__ = tuple(int(p) for p in __version__.split(".") if p.isdigit())
|
|
12
|
+
__release_date__ = "2026-10-16"
|
|
13
|
+
__status__ = "beta"
|
|
14
|
+
|
|
15
|
+
APP_ID = "io.codeberg.lahtis.Siftcode"
|
|
16
|
+
APP_NAME = "Siftcode"
|
|
17
|
+
APP_SLUG = "siftcode"
|
|
18
|
+
APP_TAGLINE = "Local LLM-powered code analysis"
|
|
19
|
+
APP_SUMMARY = "Local LLM-powered code analysis and developer guide generator"
|
|
20
|
+
|
|
21
|
+
AUTHOR_NAME = "Tuomas Lähteenmäki"
|
|
22
|
+
AUTHOR_EMAIL = "lahtis@gmail.com"
|
|
23
|
+
COPYRIGHT = "© 2026 Tuomas Lähteenmäki"
|
|
24
|
+
COPYRIGHT_YEAR = 2026
|
|
25
|
+
|
|
26
|
+
LICENSE_NAME = "GNU General Public License v3.0 or later"
|
|
27
|
+
LICENSE_SPDX = "GPL-3.0-or-later"
|
|
28
|
+
LICENSE_URL = "https://www.gnu.org/licenses/gpl-3.0.html"
|
|
29
|
+
|
|
30
|
+
HOMEPAGE_URL = "https://codeberg.org/lahtis/siftcode"
|
|
31
|
+
REPOSITORY_URL = "https://codeberg.org/lahtis/siftcode"
|
|
32
|
+
ISSUES_URL = "https://codeberg.org/lahtis/siftcode/issues"
|
|
33
|
+
CHANGELOG_URL = "https://codeberg.org/lahtis/siftcode/src/branch/main/CHANGELOG.md"
|
|
34
|
+
|
|
35
|
+
DESCRIPTION = (
|
|
36
|
+
"Siftcode analyzes software projects using a locally-running "
|
|
37
|
+
"language model (via Ollama) and produces per-file summaries, "
|
|
38
|
+
"per-directory summaries, and a comprehensive Markdown developer guide.\n\n"
|
|
39
|
+
"Everything runs on your own hardware — no code leaves your machine."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def version_string() -> str:
|
|
43
|
+
return f"{APP_NAME} {__version__} ({__status__})"
|
|
44
|
+
|
|
45
|
+
def short_version() -> str:
|
|
46
|
+
return __version__
|
|
47
|
+
|
|
48
|
+
def about_data() -> dict:
|
|
49
|
+
return {
|
|
50
|
+
"application_name": APP_NAME,
|
|
51
|
+
"application_icon": APP_ID,
|
|
52
|
+
"version": __version__,
|
|
53
|
+
"developer_name": AUTHOR_NAME,
|
|
54
|
+
"license_type": "gpl-3-0",
|
|
55
|
+
"comments": DESCRIPTION,
|
|
56
|
+
"website": HOMEPAGE_URL,
|
|
57
|
+
"issue_url": ISSUES_URL,
|
|
58
|
+
"copyright": COPYRIGHT,
|
|
59
|
+
}
|
siftcode/cli.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for Siftcode.
|
|
3
|
+
|
|
4
|
+
Provides a thin layer over the core modules:
|
|
5
|
+
- config: project save/load/list/delete
|
|
6
|
+
- cache: analysis cache management
|
|
7
|
+
- llm: Ollama client
|
|
8
|
+
- analyzer: project analysis engine
|
|
9
|
+
|
|
10
|
+
No GTK dependencies.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import logging
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from siftcode._version import ( # noqa: E402
|
|
22
|
+
APP_NAME,
|
|
23
|
+
APP_SLUG,
|
|
24
|
+
APP_SUMMARY,
|
|
25
|
+
AUTHOR_NAME,
|
|
26
|
+
COPYRIGHT,
|
|
27
|
+
HOMEPAGE_URL,
|
|
28
|
+
LICENSE_NAME,
|
|
29
|
+
LICENSE_SPDX,
|
|
30
|
+
LICENSE_URL,
|
|
31
|
+
__release_date__,
|
|
32
|
+
__status__,
|
|
33
|
+
__version__,
|
|
34
|
+
)
|
|
35
|
+
from siftcode.core.analyzer import ProjectAnalyzer
|
|
36
|
+
from siftcode.core.cache import AnalysisCache
|
|
37
|
+
from siftcode.core.config import (
|
|
38
|
+
PROJECTS_DIR,
|
|
39
|
+
ProjectConfig,
|
|
40
|
+
delete_project,
|
|
41
|
+
list_projects,
|
|
42
|
+
load_project,
|
|
43
|
+
save_project,
|
|
44
|
+
)
|
|
45
|
+
from siftcode.core.llm import LLMError, OllamaClient
|
|
46
|
+
from siftcode.core.manual import SpecGenerator
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Helpers
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def _setup_logging(verbose: bool, quiet: bool) -> None:
|
|
57
|
+
if quiet:
|
|
58
|
+
level = logging.ERROR
|
|
59
|
+
elif verbose:
|
|
60
|
+
level = logging.INFO
|
|
61
|
+
else:
|
|
62
|
+
level = logging.WARNING
|
|
63
|
+
|
|
64
|
+
logging.basicConfig(
|
|
65
|
+
level=level,
|
|
66
|
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
67
|
+
datefmt="%H:%M:%S",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve_project(name_or_path: str) -> ProjectConfig:
|
|
72
|
+
candidate = Path(name_or_path).expanduser()
|
|
73
|
+
|
|
74
|
+
if candidate.is_dir():
|
|
75
|
+
return ProjectConfig(
|
|
76
|
+
name=candidate.name,
|
|
77
|
+
path=candidate.resolve(),
|
|
78
|
+
created=datetime.now().isoformat(timespec="seconds"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
return load_project(name_or_path)
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
raise SystemExit(
|
|
85
|
+
f"error: no project found for '{name_or_path}'\n"
|
|
86
|
+
f"hint: '{name_or_path}' is not a directory and no saved project "
|
|
87
|
+
f"matches. Run '{APP_SLUG} list' to see saved projects."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_ollama(client: OllamaClient) -> None:
|
|
92
|
+
if not client.is_available():
|
|
93
|
+
raise SystemExit(
|
|
94
|
+
f"error: cannot reach Ollama at {client.config.ollama_url}\n"
|
|
95
|
+
f"hint: make sure the Ollama server is running.\n"
|
|
96
|
+
f" Try: ollama serve"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _ensure_model(client: OllamaClient, model: str) -> None:
|
|
101
|
+
if not client.has_model(model):
|
|
102
|
+
raise SystemExit(
|
|
103
|
+
f"error: model '{model}' is not installed in Ollama\n"
|
|
104
|
+
f"hint: install it with: ollama pull {model}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _print_progress(progress) -> None:
|
|
109
|
+
bar_width = 30
|
|
110
|
+
filled = int(bar_width * progress.fraction)
|
|
111
|
+
bar = "█" * filled + "░" * (bar_width - filled)
|
|
112
|
+
print(
|
|
113
|
+
f" [{bar}] {progress.fraction * 100:5.1f}% {progress.message}",
|
|
114
|
+
end="\r" if progress.fraction < 1.0 else "\n",
|
|
115
|
+
flush=True,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _human_size(num_bytes: int) -> str:
|
|
120
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
121
|
+
if num_bytes < 1024:
|
|
122
|
+
return f"{num_bytes:.1f} {unit}"
|
|
123
|
+
num_bytes /= 1024
|
|
124
|
+
return f"{num_bytes:.1f} TB"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Commands
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def cmd_analyze(args: argparse.Namespace) -> int:
|
|
132
|
+
config = _resolve_project(args.project)
|
|
133
|
+
|
|
134
|
+
if not config.path.is_dir():
|
|
135
|
+
print(f"error: project path does not exist: {config.path}", file=sys.stderr)
|
|
136
|
+
return 1
|
|
137
|
+
|
|
138
|
+
if args.model:
|
|
139
|
+
config.model.name = args.model
|
|
140
|
+
if args.num_ctx:
|
|
141
|
+
config.model.num_ctx = args.num_ctx
|
|
142
|
+
if args.timeout:
|
|
143
|
+
config.model.timeout = args.timeout
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
load_project(config.name)
|
|
147
|
+
save_project(config)
|
|
148
|
+
logger.info(f"Updated saved project '{config.name}'")
|
|
149
|
+
except FileNotFoundError:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
config.validate()
|
|
153
|
+
|
|
154
|
+
output_dir = Path(args.output).expanduser() if args.output else Path.cwd()
|
|
155
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
|
|
157
|
+
client = OllamaClient(config.model)
|
|
158
|
+
_check_ollama(client)
|
|
159
|
+
_ensure_model(client, config.model.name)
|
|
160
|
+
|
|
161
|
+
logger.info(f"Analyzing: {config.path}")
|
|
162
|
+
logger.info(f"Model: {config.model.name}")
|
|
163
|
+
logger.info(f"Context: {config.model.num_ctx} tokens")
|
|
164
|
+
|
|
165
|
+
# JOS ADVANCED-LIPPU ON PÄÄLLÄ
|
|
166
|
+
if getattr(args, "advanced", False):
|
|
167
|
+
# Kerätään samat tiedostot kuin ProjectAnalyzer (käyttäen sen sääntöjä)
|
|
168
|
+
temp_analyzer = ProjectAnalyzer(config, client)
|
|
169
|
+
files_to_analyze = temp_analyzer._collect_files()
|
|
170
|
+
|
|
171
|
+
if not files_to_analyze:
|
|
172
|
+
print("No Python files found for advanced specification generation.")
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
spec_gen = SpecGenerator(config=config, client=client)
|
|
176
|
+
specs_output_dir = output_dir / f"specs_{config.path.name}_{datetime.now():%Y%m%d_%H%M%S}"
|
|
177
|
+
specs_output_dir.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
|
|
179
|
+
print(f"Running advanced per-file technical specification for {len(files_to_analyze)} files...")
|
|
180
|
+
|
|
181
|
+
def progress_cb(current: int, total: int, path: Path) -> None:
|
|
182
|
+
rel = path.relative_to(config.path).as_posix()
|
|
183
|
+
if len(rel) > 35:
|
|
184
|
+
rel = "..." + rel[-32:]
|
|
185
|
+
fraction = current / total
|
|
186
|
+
bar_width = 20
|
|
187
|
+
filled = int(bar_width * fraction)
|
|
188
|
+
bar = "█" * filled + "░" * (bar_width - filled)
|
|
189
|
+
msg = f" [{bar}] {fraction * 100:5.1f}% ({current}/{total}) {rel}"
|
|
190
|
+
end_char = "\n" if fraction >= 1.0 else ""
|
|
191
|
+
print(f"\r\033[K{msg}", end=end_char, flush=True)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
specs_dict = spec_gen.generate_project_specs(files_to_analyze, progress_callback=progress_cb)
|
|
195
|
+
except KeyboardInterrupt:
|
|
196
|
+
print("\n\ninterrupted by user", file=sys.stderr)
|
|
197
|
+
return 130
|
|
198
|
+
except Exception as e:
|
|
199
|
+
print(f"\nerror: {e}", file=sys.stderr)
|
|
200
|
+
return 1
|
|
201
|
+
|
|
202
|
+
# Tallennetaan tiedostot levylle
|
|
203
|
+
for rel_path, markdown_content in specs_dict.items():
|
|
204
|
+
target_file = specs_output_dir / f"{rel_path}.md"
|
|
205
|
+
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
target_file.write_text(markdown_content, encoding="utf-8")
|
|
207
|
+
|
|
208
|
+
print()
|
|
209
|
+
print(f"✓ Advanced specification generation complete")
|
|
210
|
+
print(f" Output directory: {specs_output_dir}")
|
|
211
|
+
print(f" Files processed: {len(specs_dict)}")
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
# NORMAALI ANALYYSI
|
|
215
|
+
analyzer = ProjectAnalyzer(config, client)
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
for progress in analyzer.analyze_project():
|
|
219
|
+
_print_progress(progress)
|
|
220
|
+
except KeyboardInterrupt:
|
|
221
|
+
print("\n\ninterrupted by user", file=sys.stderr)
|
|
222
|
+
return 130
|
|
223
|
+
except LLMError as e:
|
|
224
|
+
print(f"\nerror: {e}", file=sys.stderr)
|
|
225
|
+
return 1
|
|
226
|
+
|
|
227
|
+
findings_path = output_dir / f"findings_{config.path.name}_{datetime.now():%Y%m%d_%H%M%S}.json"
|
|
228
|
+
guide_path = output_dir / f"guide_{config.path.name}_{datetime.now():%Y%m%d_%H%M%S}.md"
|
|
229
|
+
|
|
230
|
+
analyzer.findings.save(findings_path)
|
|
231
|
+
guide_path.write_text(analyzer.findings.guide, encoding="utf-8")
|
|
232
|
+
|
|
233
|
+
print()
|
|
234
|
+
print(f"✓ Analysis complete")
|
|
235
|
+
print(f" Findings: {findings_path}")
|
|
236
|
+
print(f" Guide: {guide_path}")
|
|
237
|
+
print(f" Errors: {len(analyzer.findings.errors)}")
|
|
238
|
+
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def cmd_add(args: argparse.Namespace) -> int:
|
|
243
|
+
project_path = Path(args.path).expanduser().resolve()
|
|
244
|
+
if not project_path.is_dir():
|
|
245
|
+
print(f"error: not a directory: {project_path}", file=sys.stderr)
|
|
246
|
+
return 1
|
|
247
|
+
|
|
248
|
+
name = args.name or project_path.name
|
|
249
|
+
config = ProjectConfig(
|
|
250
|
+
name=name,
|
|
251
|
+
path=project_path,
|
|
252
|
+
created=datetime.now().isoformat(timespec="seconds"),
|
|
253
|
+
notes=args.notes or "",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
existing = load_project(name)
|
|
258
|
+
if not args.force:
|
|
259
|
+
print(
|
|
260
|
+
f"error: project '{name}' already exists at {existing.path}\n"
|
|
261
|
+
f"hint: use --force to overwrite, or pick another name",
|
|
262
|
+
file=sys.stderr,
|
|
263
|
+
)
|
|
264
|
+
return 1
|
|
265
|
+
except FileNotFoundError:
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
path = save_project(config)
|
|
269
|
+
print(f"✓ Saved project '{name}'")
|
|
270
|
+
print(f" Config: {path}")
|
|
271
|
+
print(f" Path: {project_path}")
|
|
272
|
+
return 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
276
|
+
projects = list_projects()
|
|
277
|
+
|
|
278
|
+
if not projects:
|
|
279
|
+
print("No saved projects.")
|
|
280
|
+
print(f"Config directory: {PROJECTS_DIR}")
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
print(f"{len(projects)} saved project(s):\n")
|
|
284
|
+
for p in projects:
|
|
285
|
+
marker = "●" if p.path.is_dir() else "✗"
|
|
286
|
+
last = p.last_run[:10] if p.last_run else "never"
|
|
287
|
+
print(f" {marker} {p.name}")
|
|
288
|
+
print(f" path: {p.path}")
|
|
289
|
+
print(f" model: {p.model.name}")
|
|
290
|
+
print(f" last run: {last}")
|
|
291
|
+
if p.notes:
|
|
292
|
+
print(f" notes: {p.notes}")
|
|
293
|
+
print()
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
298
|
+
try:
|
|
299
|
+
config = load_project(args.project)
|
|
300
|
+
except FileNotFoundError:
|
|
301
|
+
print(f"error: no saved project named '{args.project}'", file=sys.stderr)
|
|
302
|
+
return 1
|
|
303
|
+
|
|
304
|
+
print(f"Project: {config.name}")
|
|
305
|
+
print(f" Path: {config.path}")
|
|
306
|
+
print(f" Created: {config.created or '(unknown)'}")
|
|
307
|
+
print(f" Last run: {config.last_run or 'never'}")
|
|
308
|
+
print()
|
|
309
|
+
print("Model:")
|
|
310
|
+
print(f" Name: {config.model.name}")
|
|
311
|
+
print(f" Ollama URL: {config.model.ollama_url}")
|
|
312
|
+
print(f" num_ctx: {config.model.num_ctx}")
|
|
313
|
+
print(f" temperature: {config.model.temperature}")
|
|
314
|
+
print(f" timeout: {config.model.timeout}s")
|
|
315
|
+
print()
|
|
316
|
+
print("Analysis:")
|
|
317
|
+
print(f" use_cache: {config.analysis.use_cache}")
|
|
318
|
+
print(f" max_file_chars: {config.analysis.max_file_chars}")
|
|
319
|
+
print(f" exclude: {len(config.analysis.exclude)} entries")
|
|
320
|
+
print(f" {', '.join(config.analysis.exclude[:5])}...")
|
|
321
|
+
if config.notes:
|
|
322
|
+
print()
|
|
323
|
+
print(f"Notes: {config.notes}")
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def cmd_delete(args: argparse.Namespace) -> int:
|
|
328
|
+
if not args.yes:
|
|
329
|
+
answer = input(f"Delete project '{args.project}'? [y/N] ").strip().lower()
|
|
330
|
+
if answer not in ("y", "yes"):
|
|
331
|
+
print("Aborted.")
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
if delete_project(args.project):
|
|
335
|
+
print(f"✓ Deleted project '{args.project}'")
|
|
336
|
+
return 0
|
|
337
|
+
print(f"error: no saved project named '{args.project}'", file=sys.stderr)
|
|
338
|
+
return 1
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def cmd_cache_stats(args: argparse.Namespace) -> int:
|
|
342
|
+
cache = AnalysisCache()
|
|
343
|
+
s = cache.stats()
|
|
344
|
+
|
|
345
|
+
print(f"Cache directory: {s['base_dir']}")
|
|
346
|
+
print()
|
|
347
|
+
print(f"{'Category':<15} {'Entries':>10} {'Size':>12}")
|
|
348
|
+
print(f"{'-' * 15} {'-' * 10} {'-' * 12}")
|
|
349
|
+
for name in ("files", "directories", "roots"):
|
|
350
|
+
info = s[name]
|
|
351
|
+
print(f"{name:<15} {info['entries']:>10} {_human_size(info['bytes']):>12}")
|
|
352
|
+
print(f"{'-' * 15} {'-' * 10} {'-' * 12}")
|
|
353
|
+
print(f"{'Total':<15} {s['total']['entries']:>10} {s['total']['human_size']:>12}")
|
|
354
|
+
return 0
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def cmd_cache_clear(args: argparse.Namespace) -> int:
|
|
358
|
+
cache = AnalysisCache()
|
|
359
|
+
|
|
360
|
+
if args.model:
|
|
361
|
+
if not args.yes:
|
|
362
|
+
answer = input(f"Clear cache for model '{args.model}'? [y/N] ").strip().lower()
|
|
363
|
+
if answer not in ("y", "yes"):
|
|
364
|
+
print("Aborted.")
|
|
365
|
+
return 0
|
|
366
|
+
removed = cache.clear(model=args.model)
|
|
367
|
+
print(f"✓ Removed {removed} cache entries for model '{args.model}'")
|
|
368
|
+
else:
|
|
369
|
+
if not args.yes:
|
|
370
|
+
answer = input("Clear the ENTIRE cache? [y/N] ").strip().lower()
|
|
371
|
+
if answer not in ("y", "yes"):
|
|
372
|
+
print("Aborted.")
|
|
373
|
+
return 0
|
|
374
|
+
removed = cache.clear()
|
|
375
|
+
print(f"✓ Removed {removed} cache entries")
|
|
376
|
+
|
|
377
|
+
return 0
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def cmd_cache_gc(args: argparse.Namespace) -> int:
|
|
381
|
+
cache = AnalysisCache()
|
|
382
|
+
removed = cache.gc(max_age_days=args.days)
|
|
383
|
+
print(f"✓ Removed {removed} cache entries older than {args.days} days")
|
|
384
|
+
return 0
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def cmd_about(args: argparse.Namespace) -> int:
|
|
388
|
+
"""Print version, author, and license info."""
|
|
389
|
+
print(f"{APP_NAME} {__version__} ({__status__})")
|
|
390
|
+
print(f"Released: {__release_date__}")
|
|
391
|
+
print()
|
|
392
|
+
print(APP_SUMMARY)
|
|
393
|
+
print()
|
|
394
|
+
print(f"Author: {AUTHOR_NAME}")
|
|
395
|
+
print(f"Copyright: {COPYRIGHT}")
|
|
396
|
+
print(f"License: {LICENSE_NAME}")
|
|
397
|
+
print(f" {LICENSE_URL}")
|
|
398
|
+
print()
|
|
399
|
+
print(f"Homepage: {HOMEPAGE_URL}")
|
|
400
|
+
return 0
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
# Parser
|
|
405
|
+
# ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
408
|
+
parser = argparse.ArgumentParser(
|
|
409
|
+
prog=APP_SLUG,
|
|
410
|
+
description=APP_SUMMARY,
|
|
411
|
+
epilog=f"Licensed under {LICENSE_SPDX}. See '{APP_SLUG} about' for details.",
|
|
412
|
+
)
|
|
413
|
+
parser.add_argument(
|
|
414
|
+
"--version", action="version",
|
|
415
|
+
version=f"{APP_SLUG} {__version__}",
|
|
416
|
+
)
|
|
417
|
+
parser.add_argument(
|
|
418
|
+
"-v", "--verbose", action="store_true",
|
|
419
|
+
help="enable debug logging",
|
|
420
|
+
)
|
|
421
|
+
parser.add_argument(
|
|
422
|
+
"-q", "--quiet", action="store_true",
|
|
423
|
+
help="only show warnings and errors",
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
427
|
+
|
|
428
|
+
# analyze
|
|
429
|
+
p_analyze = sub.add_parser("analyze", help="Analyze a project")
|
|
430
|
+
p_analyze.add_argument(
|
|
431
|
+
"project",
|
|
432
|
+
help="Path to project directory, or name of a saved project",
|
|
433
|
+
)
|
|
434
|
+
p_analyze.add_argument(
|
|
435
|
+
"-m", "--model",
|
|
436
|
+
help="Override LLM model (e.g. qwen2.5-coder:14b)",
|
|
437
|
+
)
|
|
438
|
+
p_analyze.add_argument(
|
|
439
|
+
"--num-ctx", type=int,
|
|
440
|
+
help="Override context length in tokens",
|
|
441
|
+
)
|
|
442
|
+
p_analyze.add_argument(
|
|
443
|
+
"--timeout", type=int,
|
|
444
|
+
help="Override per-request timeout in seconds",
|
|
445
|
+
)
|
|
446
|
+
p_analyze.add_argument(
|
|
447
|
+
"-o", "--output", default=".",
|
|
448
|
+
help="Directory for findings.json and guide.md (default: .)",
|
|
449
|
+
)
|
|
450
|
+
p_analyze.add_argument(
|
|
451
|
+
"--advanced", action="store_true",
|
|
452
|
+
help="Run advanced per-file technical specification generator.",
|
|
453
|
+
)
|
|
454
|
+
p_analyze.set_defaults(func=cmd_analyze)
|
|
455
|
+
|
|
456
|
+
# add
|
|
457
|
+
p_add = sub.add_parser("add", help="Register a project directory")
|
|
458
|
+
p_add.add_argument("path", help="Path to project directory")
|
|
459
|
+
p_add.add_argument("-n", "--name", help="Name to save under (default: dir name)")
|
|
460
|
+
p_add.add_argument("--notes", help="Optional notes")
|
|
461
|
+
p_add.add_argument("--force", action="store_true", help="Overwrite existing")
|
|
462
|
+
p_add.set_defaults(func=cmd_add)
|
|
463
|
+
|
|
464
|
+
# list
|
|
465
|
+
p_list = sub.add_parser("list", help="List saved projects")
|
|
466
|
+
p_list.set_defaults(func=cmd_list)
|
|
467
|
+
|
|
468
|
+
# show
|
|
469
|
+
p_show = sub.add_parser("show", help="Show a project's configuration")
|
|
470
|
+
p_show.add_argument("project", help="Saved project name")
|
|
471
|
+
p_show.set_defaults(func=cmd_show)
|
|
472
|
+
|
|
473
|
+
# delete
|
|
474
|
+
p_del = sub.add_parser("delete", help="Delete a saved project")
|
|
475
|
+
p_del.add_argument("project", help="Saved project name")
|
|
476
|
+
p_del.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
|
|
477
|
+
p_del.set_defaults(func=cmd_delete)
|
|
478
|
+
|
|
479
|
+
# cache
|
|
480
|
+
p_cache = sub.add_parser("cache", help="Manage the analysis cache")
|
|
481
|
+
cache_sub = p_cache.add_subparsers(dest="cache_command", required=True)
|
|
482
|
+
|
|
483
|
+
p_cache_stats = cache_sub.add_parser("stats", help="Show cache statistics")
|
|
484
|
+
p_cache_stats.set_defaults(func=cmd_cache_stats)
|
|
485
|
+
|
|
486
|
+
p_cache_clear = cache_sub.add_parser("clear", help="Clear cache entries")
|
|
487
|
+
p_cache_clear.add_argument(
|
|
488
|
+
"--model",
|
|
489
|
+
help="Only clear entries for this model (default: all)",
|
|
490
|
+
)
|
|
491
|
+
p_cache_clear.add_argument(
|
|
492
|
+
"-y", "--yes", action="store_true",
|
|
493
|
+
help="Skip confirmation prompt",
|
|
494
|
+
)
|
|
495
|
+
p_cache_clear.set_defaults(func=cmd_cache_clear)
|
|
496
|
+
|
|
497
|
+
p_cache_gc = cache_sub.add_parser("gc", help="Remove old cache entries")
|
|
498
|
+
p_cache_gc.add_argument(
|
|
499
|
+
"--days", type=float, default=30.0,
|
|
500
|
+
help="Remove entries not used in N days (default: 30)",
|
|
501
|
+
)
|
|
502
|
+
p_cache_gc.set_defaults(func=cmd_cache_gc)
|
|
503
|
+
|
|
504
|
+
# about
|
|
505
|
+
p_about = sub.add_parser("about", help="Show version, author, and license")
|
|
506
|
+
p_about.set_defaults(func=cmd_about)
|
|
507
|
+
|
|
508
|
+
return parser
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ---------------------------------------------------------------------------
|
|
512
|
+
# Entry point
|
|
513
|
+
# ---------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
def main(argv: list[str] | None = None) -> int:
|
|
516
|
+
parser = build_parser()
|
|
517
|
+
args = parser.parse_args(argv)
|
|
518
|
+
|
|
519
|
+
_setup_logging(getattr(args, "verbose", False), getattr(args, "quiet", False))
|
|
520
|
+
|
|
521
|
+
try:
|
|
522
|
+
return args.func(args)
|
|
523
|
+
except SystemExit as e:
|
|
524
|
+
if isinstance(e.code, str):
|
|
525
|
+
print(e.code, file=sys.stderr)
|
|
526
|
+
return 2
|
|
527
|
+
return e.code if isinstance(e.code, int) else 1
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
if __name__ == "__main__":
|
|
531
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Siftcode — local LLM-powered code analysis."""
|