repr-cli 0.1.0__py3-none-any.whl → 0.2.2__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.
repr/discovery.py CHANGED
@@ -82,6 +82,11 @@ class RepoInfo:
82
82
  }
83
83
 
84
84
 
85
+ def is_git_repo(path: Path) -> bool:
86
+ """Check if a path is a git repository."""
87
+ return (path / ".git").is_dir()
88
+
89
+
85
90
  def should_skip_directory(path: Path, skip_patterns: list[str]) -> bool:
86
91
  """Check if a directory should be skipped."""
87
92
  name = path.name
repr/doctor.py ADDED
@@ -0,0 +1,458 @@
1
+ """
2
+ Health check and diagnostics for repr CLI.
3
+
4
+ Provides comprehensive system status and actionable recommendations.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ @dataclass
13
+ class CheckResult:
14
+ """Result of a single health check."""
15
+ name: str
16
+ status: str # "ok", "warning", "error"
17
+ message: str
18
+ details: dict[str, Any] = field(default_factory=dict)
19
+ fix: str | None = None
20
+
21
+
22
+ @dataclass
23
+ class DoctorReport:
24
+ """Complete health check report."""
25
+ checks: list[CheckResult]
26
+ overall_status: str # "healthy", "warnings", "issues"
27
+ recommendations: list[str]
28
+
29
+
30
+ def check_auth() -> CheckResult:
31
+ """Check authentication status."""
32
+ from .config import is_authenticated, get_auth
33
+
34
+ if is_authenticated():
35
+ auth = get_auth()
36
+ email = auth.get("email", "unknown") if auth else "unknown"
37
+ return CheckResult(
38
+ name="Authentication",
39
+ status="ok",
40
+ message=f"Signed in as {email}",
41
+ details={"email": email},
42
+ )
43
+ else:
44
+ return CheckResult(
45
+ name="Authentication",
46
+ status="warning",
47
+ message="Not signed in",
48
+ details={},
49
+ fix="Run `repr login` to enable cloud features",
50
+ )
51
+
52
+
53
+ def check_local_llm() -> CheckResult:
54
+ """Check local LLM availability."""
55
+ from .llm import detect_local_llm, test_local_llm
56
+ from .config import get_llm_config
57
+
58
+ llm_config = get_llm_config()
59
+
60
+ # Check configured endpoint first
61
+ if llm_config.get("local_api_url"):
62
+ result = test_local_llm(
63
+ url=llm_config["local_api_url"],
64
+ model=llm_config.get("local_model"),
65
+ )
66
+ if result.success:
67
+ return CheckResult(
68
+ name="Local LLM",
69
+ status="ok",
70
+ message=f"Connected to {result.endpoint}",
71
+ details={
72
+ "provider": result.provider,
73
+ "model": result.model,
74
+ "response_time_ms": result.response_time_ms,
75
+ },
76
+ )
77
+ else:
78
+ return CheckResult(
79
+ name="Local LLM",
80
+ status="error",
81
+ message=f"Configured endpoint unreachable: {llm_config['local_api_url']}",
82
+ details={"error": result.error},
83
+ fix="Start your local LLM or run `repr llm configure`",
84
+ )
85
+
86
+ # Try auto-detection
87
+ detected = detect_local_llm()
88
+ if detected:
89
+ return CheckResult(
90
+ name="Local LLM",
91
+ status="ok",
92
+ message=f"Detected {detected.name} at {detected.url}",
93
+ details={
94
+ "provider": detected.provider,
95
+ "url": detected.url,
96
+ "models": detected.models[:5], # First 5
97
+ "default_model": detected.default_model,
98
+ },
99
+ )
100
+ else:
101
+ return CheckResult(
102
+ name="Local LLM",
103
+ status="warning",
104
+ message="No local LLM detected",
105
+ details={},
106
+ fix="Install Ollama (`ollama serve`) or run `repr llm configure`",
107
+ )
108
+
109
+
110
+ def check_tracked_repos() -> CheckResult:
111
+ """Check tracked repositories."""
112
+ from .config import get_tracked_repos
113
+
114
+ tracked = get_tracked_repos()
115
+
116
+ if not tracked:
117
+ return CheckResult(
118
+ name="Tracked Repositories",
119
+ status="warning",
120
+ message="No repositories tracked",
121
+ details={},
122
+ fix="Run `repr init` or `repr repos add <path>`",
123
+ )
124
+
125
+ # Check if repos exist
126
+ existing = []
127
+ missing = []
128
+ for repo in tracked:
129
+ path = Path(repo["path"])
130
+ if path.exists() and (path / ".git").exists():
131
+ existing.append(repo)
132
+ else:
133
+ missing.append(repo["path"])
134
+
135
+ if missing:
136
+ return CheckResult(
137
+ name="Tracked Repositories",
138
+ status="warning",
139
+ message=f"{len(existing)} repos accessible, {len(missing)} missing",
140
+ details={
141
+ "existing": len(existing),
142
+ "missing": missing,
143
+ },
144
+ fix=f"Remove missing repos: repr repos remove <path>",
145
+ )
146
+
147
+ return CheckResult(
148
+ name="Tracked Repositories",
149
+ status="ok",
150
+ message=f"{len(tracked)} repositories tracked",
151
+ details={
152
+ "count": len(tracked),
153
+ "repos": [Path(r["path"]).name for r in tracked[:5]],
154
+ },
155
+ )
156
+
157
+
158
+ def check_hooks() -> CheckResult:
159
+ """Check hook installation status."""
160
+ from .config import get_tracked_repos
161
+ from .hooks import is_hook_installed
162
+
163
+ tracked = get_tracked_repos()
164
+
165
+ if not tracked:
166
+ return CheckResult(
167
+ name="Git Hooks",
168
+ status="warning",
169
+ message="No repositories to check",
170
+ details={},
171
+ )
172
+
173
+ installed = 0
174
+ not_installed = []
175
+
176
+ for repo in tracked:
177
+ path = Path(repo["path"])
178
+ if path.exists() and is_hook_installed(path):
179
+ installed += 1
180
+ elif path.exists():
181
+ not_installed.append(Path(repo["path"]).name)
182
+
183
+ total = len([r for r in tracked if Path(r["path"]).exists()])
184
+
185
+ if installed == total:
186
+ return CheckResult(
187
+ name="Git Hooks",
188
+ status="ok",
189
+ message=f"Installed in {installed}/{total} repos",
190
+ details={"installed": installed, "total": total},
191
+ )
192
+ elif installed > 0:
193
+ return CheckResult(
194
+ name="Git Hooks",
195
+ status="warning",
196
+ message=f"Installed in {installed}/{total} repos",
197
+ details={
198
+ "installed": installed,
199
+ "total": total,
200
+ "missing": not_installed[:5],
201
+ },
202
+ fix="Run `repr hooks install --all` to install in all repos",
203
+ )
204
+ else:
205
+ return CheckResult(
206
+ name="Git Hooks",
207
+ status="warning",
208
+ message="No hooks installed",
209
+ details={},
210
+ fix="Run `repr hooks install --all` to enable auto-tracking",
211
+ )
212
+
213
+
214
+ def check_story_queue() -> CheckResult:
215
+ """Check commit queue status."""
216
+ from .config import get_tracked_repos, get_llm_config
217
+ from .hooks import load_queue
218
+
219
+ tracked = get_tracked_repos()
220
+ llm_config = get_llm_config()
221
+ batch_size = llm_config.get("batch_size", 5)
222
+
223
+ total_queued = 0
224
+ repos_with_queue = []
225
+
226
+ for repo in tracked:
227
+ path = Path(repo["path"])
228
+ if path.exists():
229
+ queue = load_queue(path)
230
+ if queue:
231
+ total_queued += len(queue)
232
+ repos_with_queue.append({
233
+ "name": path.name,
234
+ "count": len(queue),
235
+ })
236
+
237
+ if total_queued == 0:
238
+ return CheckResult(
239
+ name="Commit Queue",
240
+ status="ok",
241
+ message="No commits pending",
242
+ details={},
243
+ )
244
+ elif total_queued >= batch_size:
245
+ return CheckResult(
246
+ name="Commit Queue",
247
+ status="warning",
248
+ message=f"{total_queued} commits pending (batch size: {batch_size})",
249
+ details={
250
+ "total": total_queued,
251
+ "batch_size": batch_size,
252
+ "repos": repos_with_queue,
253
+ },
254
+ fix="Run `repr generate` to create stories from pending commits",
255
+ )
256
+ else:
257
+ return CheckResult(
258
+ name="Commit Queue",
259
+ status="ok",
260
+ message=f"{total_queued} commits queued (batch: {batch_size})",
261
+ details={
262
+ "total": total_queued,
263
+ "batch_size": batch_size,
264
+ },
265
+ )
266
+
267
+
268
+ def check_unpushed_stories() -> CheckResult:
269
+ """Check for unpushed stories."""
270
+ from .storage import get_unpushed_stories, get_story_count
271
+ from .config import is_authenticated
272
+
273
+ total = get_story_count()
274
+ unpushed = get_unpushed_stories()
275
+
276
+ if total == 0:
277
+ return CheckResult(
278
+ name="Stories",
279
+ status="warning",
280
+ message="No stories generated yet",
281
+ details={},
282
+ fix="Run `repr generate` to create stories from your commits",
283
+ )
284
+
285
+ if not unpushed:
286
+ return CheckResult(
287
+ name="Stories",
288
+ status="ok",
289
+ message=f"{total} stories (all synced)",
290
+ details={"total": total, "unpushed": 0},
291
+ )
292
+
293
+ if is_authenticated():
294
+ return CheckResult(
295
+ name="Stories",
296
+ status="warning",
297
+ message=f"{total} stories ({len(unpushed)} not pushed)",
298
+ details={"total": total, "unpushed": len(unpushed)},
299
+ fix="Run `repr push` to publish unpushed stories",
300
+ )
301
+ else:
302
+ return CheckResult(
303
+ name="Stories",
304
+ status="ok",
305
+ message=f"{total} stories (local only)",
306
+ details={"total": total, "unpushed": len(unpushed)},
307
+ )
308
+
309
+
310
+ def check_stories_needing_review() -> CheckResult:
311
+ """Check for stories needing review."""
312
+ from .storage import get_stories_needing_review
313
+
314
+ needs_review = get_stories_needing_review()
315
+
316
+ if not needs_review:
317
+ return CheckResult(
318
+ name="Story Review",
319
+ status="ok",
320
+ message="No stories need review",
321
+ details={},
322
+ )
323
+ else:
324
+ return CheckResult(
325
+ name="Story Review",
326
+ status="warning",
327
+ message=f"{len(needs_review)} stories need review",
328
+ details={
329
+ "count": len(needs_review),
330
+ "stories": [s.get("summary", s.get("id", ""))[:50] for s in needs_review[:3]],
331
+ },
332
+ fix="Run `repr stories review` to review flagged stories",
333
+ )
334
+
335
+
336
+ def check_privacy_settings() -> CheckResult:
337
+ """Check privacy configuration."""
338
+ from .config import get_privacy_settings, is_cloud_allowed
339
+
340
+ privacy = get_privacy_settings()
341
+
342
+ if privacy.get("lock_permanent"):
343
+ return CheckResult(
344
+ name="Privacy",
345
+ status="ok",
346
+ message="Local-only mode (permanent)",
347
+ details={"lock_permanent": True},
348
+ )
349
+ elif privacy.get("lock_local_only"):
350
+ return CheckResult(
351
+ name="Privacy",
352
+ status="ok",
353
+ message="Local-only mode enabled",
354
+ details={"lock_local_only": True},
355
+ )
356
+ elif is_cloud_allowed():
357
+ return CheckResult(
358
+ name="Privacy",
359
+ status="ok",
360
+ message="Cloud features enabled",
361
+ details={
362
+ "telemetry": privacy.get("telemetry_enabled", False),
363
+ "visibility": privacy.get("profile_visibility", "public"),
364
+ },
365
+ )
366
+ else:
367
+ return CheckResult(
368
+ name="Privacy",
369
+ status="ok",
370
+ message="Local mode (not authenticated)",
371
+ details={},
372
+ )
373
+
374
+
375
+ def check_byok() -> CheckResult:
376
+ """Check BYOK provider configuration."""
377
+ from .config import list_byok_providers, get_byok_config
378
+
379
+ providers = list_byok_providers()
380
+
381
+ if not providers:
382
+ return CheckResult(
383
+ name="BYOK Providers",
384
+ status="ok",
385
+ message="No BYOK providers configured",
386
+ details={},
387
+ )
388
+
389
+ # Verify each provider has valid config
390
+ valid = []
391
+ invalid = []
392
+
393
+ for provider in providers:
394
+ config = get_byok_config(provider)
395
+ if config and config.get("api_key"):
396
+ valid.append(provider)
397
+ else:
398
+ invalid.append(provider)
399
+
400
+ if invalid:
401
+ return CheckResult(
402
+ name="BYOK Providers",
403
+ status="warning",
404
+ message=f"{len(valid)} valid, {len(invalid)} misconfigured",
405
+ details={
406
+ "valid": valid,
407
+ "invalid": invalid,
408
+ },
409
+ fix=f"Reconfigure: repr llm add {invalid[0]}",
410
+ )
411
+
412
+ return CheckResult(
413
+ name="BYOK Providers",
414
+ status="ok",
415
+ message=f"{len(valid)} providers configured",
416
+ details={"providers": valid},
417
+ )
418
+
419
+
420
+ def run_all_checks() -> DoctorReport:
421
+ """
422
+ Run all health checks and generate report.
423
+
424
+ Returns:
425
+ DoctorReport with all check results
426
+ """
427
+ checks = [
428
+ check_auth(),
429
+ check_local_llm(),
430
+ check_tracked_repos(),
431
+ check_hooks(),
432
+ check_story_queue(),
433
+ check_unpushed_stories(),
434
+ check_stories_needing_review(),
435
+ check_privacy_settings(),
436
+ check_byok(),
437
+ ]
438
+
439
+ # Determine overall status
440
+ has_errors = any(c.status == "error" for c in checks)
441
+ has_warnings = any(c.status == "warning" for c in checks)
442
+
443
+ if has_errors:
444
+ overall_status = "issues"
445
+ elif has_warnings:
446
+ overall_status = "warnings"
447
+ else:
448
+ overall_status = "healthy"
449
+
450
+ # Collect recommendations
451
+ recommendations = [c.fix for c in checks if c.fix and c.status in ("warning", "error")]
452
+
453
+ return DoctorReport(
454
+ checks=checks,
455
+ overall_status=overall_status,
456
+ recommendations=recommendations,
457
+ )
458
+