debtpilot 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.
- debtpilot-0.1.0.dist-info/METADATA +143 -0
- debtpilot-0.1.0.dist-info/RECORD +20 -0
- debtpilot-0.1.0.dist-info/WHEEL +5 -0
- debtpilot-0.1.0.dist-info/entry_points.txt +2 -0
- debtpilot-0.1.0.dist-info/top_level.txt +1 -0
- debtpilot_core/__init__.py +6 -0
- debtpilot_core/ai_advisor.py +206 -0
- debtpilot_core/autostart.py +255 -0
- debtpilot_core/cli.py +682 -0
- debtpilot_core/daemon.py +263 -0
- debtpilot_core/dashboard.py +289 -0
- debtpilot_core/database.py +271 -0
- debtpilot_core/digest.py +227 -0
- debtpilot_core/hooks.py +149 -0
- debtpilot_core/linter.py +153 -0
- debtpilot_core/notifier.py +247 -0
- debtpilot_core/predictor.py +223 -0
- debtpilot_core/reporter.py +210 -0
- debtpilot_core/scanner.py +177 -0
- debtpilot_core/scorer.py +138 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: debtpilot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Track technical debt before it blows up in production
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/YOUR_GITHUB_USERNAME/debtpilot
|
|
7
|
+
Project-URL: Issues, https://github.com/YOUR_GITHUB_USERNAME/debtpilot/issues
|
|
8
|
+
Keywords: technical-debt,code-quality,developer-tools,cli,linting
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: rich>=13.0
|
|
22
|
+
Provides-Extra: daemon
|
|
23
|
+
Requires-Dist: websockets>=12.0; extra == "daemon"
|
|
24
|
+
Requires-Dist: watchdog>=4.0; extra == "daemon"
|
|
25
|
+
Provides-Extra: notify
|
|
26
|
+
Requires-Dist: win10toast>=0.9; platform_system == "Windows" and extra == "notify"
|
|
27
|
+
Requires-Dist: plyer>=2.1; extra == "notify"
|
|
28
|
+
Provides-Extra: full
|
|
29
|
+
Requires-Dist: debtpilot[daemon,notify]; extra == "full"
|
|
30
|
+
|
|
31
|
+
# DebtPilot
|
|
32
|
+
|
|
33
|
+
> Track technical debt before it blows up in production.
|
|
34
|
+
|
|
35
|
+
Every IDE catches syntax errors. DebtPilot does something no IDE can —
|
|
36
|
+
it knows that `payments/stripe.js` has been edited 30 times by 4 different
|
|
37
|
+
developers, has a TODO from 8 months ago, and a complexity score of 24.
|
|
38
|
+
That combination is your next production incident.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
pip install debtpilot
|
|
45
|
+
|
|
46
|
+
Full install (file watcher + live notifications):
|
|
47
|
+
|
|
48
|
+
pip install "debtpilot[full]"
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Quickstart — 4 commands, done
|
|
53
|
+
|
|
54
|
+
1. Scan your project
|
|
55
|
+
debtpilot scan .
|
|
56
|
+
|
|
57
|
+
2. See the report
|
|
58
|
+
debtpilot report
|
|
59
|
+
|
|
60
|
+
3. Block bad commits automatically
|
|
61
|
+
debtpilot hook --install
|
|
62
|
+
|
|
63
|
+
4. Start live background watching
|
|
64
|
+
debtpilot start --dir .
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## What it does
|
|
69
|
+
|
|
70
|
+
| Command | What happens |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `debtpilot scan .` | Scores every file 0-100 for technical debt |
|
|
73
|
+
| `debtpilot report` | Shows top debt files in a ranked table |
|
|
74
|
+
| `debtpilot file <path>` | Deep breakdown of one file |
|
|
75
|
+
| `debtpilot ai <path>` | AI explains exactly what to fix (needs Gemini key) |
|
|
76
|
+
| `debtpilot predict` | Predicts which file will cause your next bug |
|
|
77
|
+
| `debtpilot dashboard` | Generates an HTML dashboard — open in browser |
|
|
78
|
+
| `debtpilot report-pdf` | Generates a print-ready PDF report |
|
|
79
|
+
| `debtpilot alerts` | Shows active danger alerts |
|
|
80
|
+
| `debtpilot todos` | Lists stale TODOs by age |
|
|
81
|
+
| `debtpilot hook --install` | Blocks commits above the debt threshold |
|
|
82
|
+
| `debtpilot start` | Starts live file watcher + WebSocket server |
|
|
83
|
+
| `debtpilot status` | Full health check of your DebtPilot setup |
|
|
84
|
+
| `debtpilot digest --send` | Sends weekly email summary to your team |
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Debt Score (0-100)
|
|
89
|
+
|
|
90
|
+
| Grade | Score | Meaning |
|
|
91
|
+
|-------|-------|---------|
|
|
92
|
+
| A | 0-25 | Healthy |
|
|
93
|
+
| B | 26-45 | Watch |
|
|
94
|
+
| C | 46-65 | Concerning |
|
|
95
|
+
| D | 66-80 | Dangerous |
|
|
96
|
+
| F | 81-100 | Critical — fix before next deploy |
|
|
97
|
+
|
|
98
|
+
Score = weighted combination of: git churn (30%) + complexity (25%) +
|
|
99
|
+
lint issues (20%) + stale TODOs (15%) + staleness (10%).
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## AI Fix Suggestions (optional)
|
|
104
|
+
|
|
105
|
+
Get a free Gemini API key at aistudio.google.com then:
|
|
106
|
+
|
|
107
|
+
debtpilot config --gemini-key YOUR_KEY
|
|
108
|
+
debtpilot ai src/payments.py
|
|
109
|
+
|
|
110
|
+
DebtPilot sends the file context to Gemini and gets back exact fix
|
|
111
|
+
suggestions with code examples and effort estimates.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Weekly Email Digest
|
|
116
|
+
|
|
117
|
+
debtpilot config --gmail you@gmail.com --gmail-password YOUR_APP_PASS --recipients team@company.com
|
|
118
|
+
debtpilot digest --send
|
|
119
|
+
|
|
120
|
+
Use a Gmail App Password from: myaccount.google.com/apppasswords
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Auto-start on Windows login
|
|
125
|
+
|
|
126
|
+
debtpilot autostart --install --dir .
|
|
127
|
+
|
|
128
|
+
DebtPilot will start silently in the background every time Windows starts.
|
|
129
|
+
No manual debtpilot start needed.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Requirements
|
|
134
|
+
|
|
135
|
+
- Python 3.10 or higher
|
|
136
|
+
- git in PATH
|
|
137
|
+
- pylint / mypy / eslint — optional, for richer lint scores
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
debtpilot_core/__init__.py,sha256=7MH_x0z0JSLcdSAxEfT2-UMCNUfGl5Hm3jFbmtTTcpw,129
|
|
2
|
+
debtpilot_core/ai_advisor.py,sha256=MBrmwIXDY4iclAXQyK-tSu2YL3ygUa0KT3qL6GRzO5E,6813
|
|
3
|
+
debtpilot_core/autostart.py,sha256=06X-waesS_7GM1HoHkW_7XGHJ_M3ic1sF0K0M--D-18,8934
|
|
4
|
+
debtpilot_core/cli.py,sha256=4UjYThoFz1PH1-Y2hR2zgh5Sji88LRzfWRXR2pTIpyU,26874
|
|
5
|
+
debtpilot_core/daemon.py,sha256=T40hRan2ONQKQKEYslDoNGFyK010wO8gPXNt-2deIDM,9300
|
|
6
|
+
debtpilot_core/dashboard.py,sha256=j9m2fGWRSF6bPhe4KGVZSQR9KQh99AiPkIp6rhoQu5o,11944
|
|
7
|
+
debtpilot_core/database.py,sha256=jWAH9p2TtkcYGphK24ra2J72hiZaQT0RO0fcNnpAl6Q,9322
|
|
8
|
+
debtpilot_core/digest.py,sha256=B_CIQf-CyOgBhKV_RDg4nt8pJgwBfe8KJzRFTt4FDo8,9865
|
|
9
|
+
debtpilot_core/hooks.py,sha256=2Fcro5AopZWMKFno11MJfgrWHyWRJ6Gs1YX9E2vY-I8,5169
|
|
10
|
+
debtpilot_core/linter.py,sha256=SntpTxopFEmnXNGU4Ktkdcrr8n7Ey5XbXA2Jf421EwI,4871
|
|
11
|
+
debtpilot_core/notifier.py,sha256=CgmeKxa3ZVW9H0rD22wGn_e_4--j7vVLG0vdhv7_qKs,7979
|
|
12
|
+
debtpilot_core/predictor.py,sha256=Eo5bEmQgi0sKVj4rKPC4XcRGYLAfNlsgM2Ql6aB2mr4,7828
|
|
13
|
+
debtpilot_core/reporter.py,sha256=_nFYq6jdYdKeDpnzyHxbndcXjjXjfQwoRgQGuPf5fEE,8590
|
|
14
|
+
debtpilot_core/scanner.py,sha256=qoXYw9xrk8NDDAWDi2xPJebjFEUu1Zt1AO3MMNwQIko,6541
|
|
15
|
+
debtpilot_core/scorer.py,sha256=7heZFLW-u-P4S00obpPjy9zw_erDY2ZjnBIS9ZExoXo,4321
|
|
16
|
+
debtpilot-0.1.0.dist-info/METADATA,sha256=lrjRqwz1iVsFmhu4MYlo7Y5cXk_6Wbvabi0OSIlSDhM,4240
|
|
17
|
+
debtpilot-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
18
|
+
debtpilot-0.1.0.dist-info/entry_points.txt,sha256=aVse1t0vJT44p9SxUH8IlTqTi66qQQvvT-jVtld1d1M,54
|
|
19
|
+
debtpilot-0.1.0.dist-info/top_level.txt,sha256=sJNOnUKGzPh8dq_N45_RGTlrPafldV3W1Aw8IMInsgU,15
|
|
20
|
+
debtpilot-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
debtpilot_core
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DebtPilot - AI Fix Suggestions using Google Gemini (free API).
|
|
3
|
+
Sends risky code to Gemini and gets back exact fix suggestions.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
import urllib.request
|
|
9
|
+
import urllib.error
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_api_key() -> Optional[str]:
|
|
17
|
+
# First check environment variable
|
|
18
|
+
key = os.environ.get("DEBTPILOT_GEMINI_KEY")
|
|
19
|
+
if key:
|
|
20
|
+
return key
|
|
21
|
+
# Then check config file
|
|
22
|
+
config_path = Path.home() / ".debtpilot" / "config.json"
|
|
23
|
+
if config_path.exists():
|
|
24
|
+
try:
|
|
25
|
+
data = json.loads(config_path.read_text())
|
|
26
|
+
return data.get("gemini_api_key")
|
|
27
|
+
except Exception:
|
|
28
|
+
pass
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def save_api_key(api_key: str) -> None:
|
|
33
|
+
config_dir = Path.home() / ".debtpilot"
|
|
34
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
config_path = config_dir / "config.json"
|
|
36
|
+
data = {}
|
|
37
|
+
if config_path.exists():
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(config_path.read_text())
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
data["gemini_api_key"] = api_key
|
|
43
|
+
config_path.write_text(json.dumps(data, indent=2))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _call_gemini(prompt: str, api_key: str) -> str:
|
|
47
|
+
payload = json.dumps({
|
|
48
|
+
"contents": [{"parts": [{"text": prompt}]}],
|
|
49
|
+
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 1500}
|
|
50
|
+
}).encode()
|
|
51
|
+
|
|
52
|
+
req = urllib.request.Request(
|
|
53
|
+
f"{GEMINI_API_URL}?key={api_key}",
|
|
54
|
+
data=payload,
|
|
55
|
+
headers={"Content-Type": "application/json"},
|
|
56
|
+
method="POST",
|
|
57
|
+
)
|
|
58
|
+
try:
|
|
59
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
60
|
+
result = json.loads(r.read())
|
|
61
|
+
return result["candidates"][0]["content"]["parts"][0]["text"]
|
|
62
|
+
except urllib.error.HTTPError as e:
|
|
63
|
+
body = e.read().decode()
|
|
64
|
+
raise RuntimeError(f"Gemini API error {e.code}: {body}")
|
|
65
|
+
except Exception as e:
|
|
66
|
+
raise RuntimeError(f"Failed to call Gemini: {e}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def analyze_file(
|
|
70
|
+
filepath: str,
|
|
71
|
+
score_data: Dict,
|
|
72
|
+
lint_issues: List[Dict],
|
|
73
|
+
todos: List[Dict],
|
|
74
|
+
complexity: Dict,
|
|
75
|
+
) -> Dict:
|
|
76
|
+
"""
|
|
77
|
+
Send file context to Gemini and get back structured fix suggestions.
|
|
78
|
+
Returns dict with summary, fixes, and priority actions.
|
|
79
|
+
"""
|
|
80
|
+
api_key = get_api_key()
|
|
81
|
+
if not api_key:
|
|
82
|
+
return {
|
|
83
|
+
"error": "No Gemini API key configured. Run: debtpilot config --gemini-key YOUR_KEY",
|
|
84
|
+
"summary": "AI advisor not configured. The rest of DebtPilot works without it.",
|
|
85
|
+
"fixes": [],
|
|
86
|
+
"priority_actions": ["Run: debtpilot config --gemini-key YOUR_KEY to enable AI suggestions"],
|
|
87
|
+
"risk_level": "UNKNOWN",
|
|
88
|
+
"estimated_fix_time": "N/A",
|
|
89
|
+
"ai_available": False,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# Read file content (first 150 lines to stay within token limits)
|
|
93
|
+
try:
|
|
94
|
+
lines = Path(filepath).read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
95
|
+
code_preview = "\n".join(lines[:150])
|
|
96
|
+
total_lines = len(lines)
|
|
97
|
+
except OSError:
|
|
98
|
+
code_preview = "(could not read file)"
|
|
99
|
+
total_lines = 0
|
|
100
|
+
|
|
101
|
+
# Build lint summary
|
|
102
|
+
errors = [i for i in lint_issues if i.get("severity") == "error"][:5]
|
|
103
|
+
warnings = [i for i in lint_issues if i.get("severity") == "warning"][:5]
|
|
104
|
+
lint_summary = "\n".join(
|
|
105
|
+
[f" ERROR L{i['line']}: {i['message']}" for i in errors] +
|
|
106
|
+
[f" WARNING L{i['line']}: {i['message']}" for i in warnings]
|
|
107
|
+
) or " None detected"
|
|
108
|
+
|
|
109
|
+
# Build TODO summary
|
|
110
|
+
todo_summary = "\n".join(
|
|
111
|
+
[f" L{t['line']} ({t.get('age_days', 0)} days old): {t.get('text', '')}" for t in todos[:5]]
|
|
112
|
+
) or " None"
|
|
113
|
+
|
|
114
|
+
# Build complexity summary
|
|
115
|
+
funcs = complexity.get("functions", [])[:5]
|
|
116
|
+
complexity_summary = "\n".join(
|
|
117
|
+
[f" {f['name']}() at L{f['line']}: complexity {f['complexity']}" for f in funcs]
|
|
118
|
+
) or f" avg={complexity.get('avg_complexity', 0)}"
|
|
119
|
+
|
|
120
|
+
prompt = f"""You are a senior software engineer doing a technical debt review.
|
|
121
|
+
|
|
122
|
+
FILE: {filepath}
|
|
123
|
+
TOTAL LINES: {total_lines}
|
|
124
|
+
DEBT SCORE: {score_data.get('total', 0)}/100 (Grade {score_data.get('grade', '?')} — {score_data.get('label', '')})
|
|
125
|
+
|
|
126
|
+
SUB-SCORES:
|
|
127
|
+
Churn (how often edited by many people): {score_data.get('churn_score', 0)}/100
|
|
128
|
+
Complexity (how hard to understand): {score_data.get('complexity_score', 0)}/100
|
|
129
|
+
Lint issues: {score_data.get('lint_score', 0)}/100
|
|
130
|
+
Stale TODOs: {score_data.get('todo_score', 0)}/100
|
|
131
|
+
Staleness: {score_data.get('staleness_score', 0)}/100
|
|
132
|
+
|
|
133
|
+
LINT ISSUES:
|
|
134
|
+
{lint_summary}
|
|
135
|
+
|
|
136
|
+
STALE TODOs:
|
|
137
|
+
{todo_summary}
|
|
138
|
+
|
|
139
|
+
COMPLEX FUNCTIONS:
|
|
140
|
+
{complexity_summary}
|
|
141
|
+
|
|
142
|
+
CODE PREVIEW (first 150 lines):
|
|
143
|
+
{code_preview}
|
|
144
|
+
|
|
145
|
+
Respond ONLY with a valid JSON object in this exact format, no markdown, no explanation outside the JSON:
|
|
146
|
+
{{
|
|
147
|
+
"summary": "2-3 sentence plain English summary of the main problems",
|
|
148
|
+
"risk_level": "LOW or MEDIUM or HIGH or CRITICAL",
|
|
149
|
+
"fixes": [
|
|
150
|
+
{{
|
|
151
|
+
"title": "short title of the fix",
|
|
152
|
+
"problem": "what is wrong and why it matters",
|
|
153
|
+
"solution": "exactly what to do",
|
|
154
|
+
"code_example": "short before/after code snippet if relevant, else empty string",
|
|
155
|
+
"effort": "15min or 1hr or half-day or 1day"
|
|
156
|
+
}}
|
|
157
|
+
],
|
|
158
|
+
"priority_actions": [
|
|
159
|
+
"action 1 — most important thing to do first",
|
|
160
|
+
"action 2",
|
|
161
|
+
"action 3"
|
|
162
|
+
],
|
|
163
|
+
"estimated_fix_time": "total estimated time to resolve all issues"
|
|
164
|
+
}}"""
|
|
165
|
+
|
|
166
|
+
raw = _call_gemini(prompt, api_key)
|
|
167
|
+
|
|
168
|
+
# Strip markdown code fences if present
|
|
169
|
+
raw = raw.strip()
|
|
170
|
+
if raw.startswith("```"):
|
|
171
|
+
raw = raw.split("```")[1]
|
|
172
|
+
if raw.startswith("json"):
|
|
173
|
+
raw = raw[4:]
|
|
174
|
+
raw = raw.strip()
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
return json.loads(raw)
|
|
178
|
+
except json.JSONDecodeError:
|
|
179
|
+
return {
|
|
180
|
+
"summary": raw[:500],
|
|
181
|
+
"risk_level": "UNKNOWN",
|
|
182
|
+
"fixes": [],
|
|
183
|
+
"priority_actions": [],
|
|
184
|
+
"estimated_fix_time": "unknown",
|
|
185
|
+
"parse_error": True,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def quick_explain(code_snippet: str, question: str) -> str:
|
|
190
|
+
"""Ask Gemini a quick question about a code snippet."""
|
|
191
|
+
api_key = get_api_key()
|
|
192
|
+
if not api_key:
|
|
193
|
+
return "No Gemini API key configured. Run: debtpilot config --gemini-key YOUR_KEY"
|
|
194
|
+
|
|
195
|
+
prompt = f"""You are a helpful senior developer. Answer concisely.
|
|
196
|
+
|
|
197
|
+
Question: {question}
|
|
198
|
+
|
|
199
|
+
Code:{code_snippet[:1000]}
|
|
200
|
+
|
|
201
|
+
Give a direct, practical answer in 3-5 sentences maximum."""
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
return _call_gemini(prompt, api_key)
|
|
205
|
+
except RuntimeError as e:
|
|
206
|
+
return f"AI error: {e}"
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DebtPilot - Windows Auto-startup Manager.
|
|
3
|
+
Registers DebtPilot daemon with Windows Task Scheduler so it starts
|
|
4
|
+
automatically on login — no manual 'debtpilot start' ever needed again.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Dict, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
TASK_NAME = "DebtPilotDaemon"
|
|
16
|
+
CONFIG_PATH = Path.home() / ".debtpilot" / "config.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_config() -> Dict:
|
|
20
|
+
if CONFIG_PATH.exists():
|
|
21
|
+
try:
|
|
22
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _save_config(data: Dict) -> None:
|
|
29
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
existing = _get_config()
|
|
31
|
+
existing.update(data)
|
|
32
|
+
CONFIG_PATH.write_text(json.dumps(existing, indent=2))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_python_path() -> str:
|
|
36
|
+
return sys.executable
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_debtpilot_path() -> Optional[str]:
|
|
40
|
+
"""Find the debtpilot CLI script path."""
|
|
41
|
+
import shutil
|
|
42
|
+
path = shutil.which("debtpilot")
|
|
43
|
+
if path:
|
|
44
|
+
return path
|
|
45
|
+
# Fallback: construct from Python path
|
|
46
|
+
scripts = Path(sys.executable).parent / "Scripts" / "debtpilot.exe"
|
|
47
|
+
if scripts.exists():
|
|
48
|
+
return str(scripts)
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_project_dir() -> str:
|
|
53
|
+
config = _get_config()
|
|
54
|
+
return config.get("watched_project", str(Path.home()))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── Windows Task Scheduler ───────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def _create_task_xml(python_path: str, project_dir: str, port: int) -> str:
|
|
60
|
+
"""Generate the XML for Windows Task Scheduler."""
|
|
61
|
+
debtpilot = _get_debtpilot_path() or f"{Path(python_path).parent}\\Scripts\\debtpilot.exe"
|
|
62
|
+
|
|
63
|
+
return f"""<?xml version="1.0" encoding="UTF-16"?>
|
|
64
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
65
|
+
<RegistrationInfo>
|
|
66
|
+
<Description>DebtPilot — Live technical debt monitoring daemon</Description>
|
|
67
|
+
<Author>DebtPilot</Author>
|
|
68
|
+
</RegistrationInfo>
|
|
69
|
+
<Triggers>
|
|
70
|
+
<LogonTrigger>
|
|
71
|
+
<Enabled>true</Enabled>
|
|
72
|
+
</LogonTrigger>
|
|
73
|
+
</Triggers>
|
|
74
|
+
<Principals>
|
|
75
|
+
<Principal id="Author">
|
|
76
|
+
<LogonType>InteractiveToken</LogonType>
|
|
77
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
78
|
+
</Principal>
|
|
79
|
+
</Principals>
|
|
80
|
+
<Settings>
|
|
81
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
82
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
83
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
84
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
85
|
+
<Priority>7</Priority>
|
|
86
|
+
</Settings>
|
|
87
|
+
<Actions Context="Author">
|
|
88
|
+
<Exec>
|
|
89
|
+
<Command>{debtpilot}</Command>
|
|
90
|
+
<Arguments>start --dir "{project_dir}" --port {port}</Arguments>
|
|
91
|
+
<WorkingDirectory>{project_dir}</WorkingDirectory>
|
|
92
|
+
</Exec>
|
|
93
|
+
</Actions>
|
|
94
|
+
</Task>"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def install_autostart(project_dir: Optional[str] = None, port: int = 57017) -> Dict:
|
|
98
|
+
"""
|
|
99
|
+
Register DebtPilot with Windows Task Scheduler.
|
|
100
|
+
Runs automatically on every login.
|
|
101
|
+
"""
|
|
102
|
+
if sys.platform != "win32":
|
|
103
|
+
return {
|
|
104
|
+
"success": False,
|
|
105
|
+
"error": "Autostart is currently Windows-only. On Mac/Linux, add 'debtpilot start' to your shell profile."
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
project_dir = project_dir or str(Path.cwd())
|
|
109
|
+
python_path = _get_python_path()
|
|
110
|
+
|
|
111
|
+
# Save project dir to config
|
|
112
|
+
_save_config({"watched_project": project_dir, "daemon_port": port})
|
|
113
|
+
|
|
114
|
+
# Write the task XML to a temp file
|
|
115
|
+
xml_content = _create_task_xml(python_path, project_dir, port)
|
|
116
|
+
xml_path = Path.home() / ".debtpilot" / "task.xml"
|
|
117
|
+
xml_path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
xml_path.write_text(xml_content, encoding="utf-16")
|
|
121
|
+
except Exception as e:
|
|
122
|
+
return {"success": False, "error": f"Could not write task file: {e}"}
|
|
123
|
+
|
|
124
|
+
# Register with schtasks
|
|
125
|
+
try:
|
|
126
|
+
result = subprocess.run(
|
|
127
|
+
["schtasks", "/Create", "/TN", TASK_NAME,
|
|
128
|
+
"/XML", str(xml_path), "/F"],
|
|
129
|
+
capture_output=True, text=True, timeout=30
|
|
130
|
+
)
|
|
131
|
+
if result.returncode == 0:
|
|
132
|
+
_save_config({"autostart": True})
|
|
133
|
+
return {
|
|
134
|
+
"success": True,
|
|
135
|
+
"message": (
|
|
136
|
+
f"✅ DebtPilot will now start automatically on every login.\n"
|
|
137
|
+
f" Watching: {project_dir}\n"
|
|
138
|
+
f" Port: {port}\n"
|
|
139
|
+
f" Task name: {TASK_NAME}"
|
|
140
|
+
),
|
|
141
|
+
"project_dir": project_dir,
|
|
142
|
+
"port": port,
|
|
143
|
+
}
|
|
144
|
+
else:
|
|
145
|
+
# Try running as admin via PowerShell elevation
|
|
146
|
+
return _install_with_elevation(xml_path, project_dir, port)
|
|
147
|
+
except FileNotFoundError:
|
|
148
|
+
return {"success": False, "error": "schtasks not found. Are you on Windows?"}
|
|
149
|
+
except Exception as e:
|
|
150
|
+
return {"success": False, "error": str(e)}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _install_with_elevation(xml_path: Path, project_dir: str, port: int) -> Dict:
|
|
154
|
+
"""Try to install task with admin elevation via PowerShell."""
|
|
155
|
+
try:
|
|
156
|
+
ps_command = (
|
|
157
|
+
f'Start-Process schtasks -ArgumentList '
|
|
158
|
+
f'"/Create /TN {TASK_NAME} /XML `"{xml_path}`" /F" '
|
|
159
|
+
f'-Verb RunAs -Wait'
|
|
160
|
+
)
|
|
161
|
+
result = subprocess.run(
|
|
162
|
+
["powershell", "-Command", ps_command],
|
|
163
|
+
capture_output=True, text=True, timeout=30
|
|
164
|
+
)
|
|
165
|
+
if result.returncode == 0:
|
|
166
|
+
_save_config({"autostart": True})
|
|
167
|
+
return {
|
|
168
|
+
"success": True,
|
|
169
|
+
"message": (
|
|
170
|
+
f"✅ Autostart installed with admin rights.\n"
|
|
171
|
+
f" Watching: {project_dir}"
|
|
172
|
+
),
|
|
173
|
+
}
|
|
174
|
+
else:
|
|
175
|
+
return {
|
|
176
|
+
"success": False,
|
|
177
|
+
"error": (
|
|
178
|
+
"Could not register with Task Scheduler.\n"
|
|
179
|
+
"Try running your terminal as Administrator and retry:\n"
|
|
180
|
+
" debtpilot autostart --install"
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
except Exception as e:
|
|
184
|
+
return {"success": False, "error": str(e)}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def uninstall_autostart() -> Dict:
|
|
188
|
+
"""Remove DebtPilot from Windows Task Scheduler."""
|
|
189
|
+
if sys.platform != "win32":
|
|
190
|
+
return {"success": False, "error": "Not on Windows"}
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
result = subprocess.run(
|
|
194
|
+
["schtasks", "/Delete", "/TN", TASK_NAME, "/F"],
|
|
195
|
+
capture_output=True, text=True, timeout=15
|
|
196
|
+
)
|
|
197
|
+
if result.returncode == 0:
|
|
198
|
+
_save_config({"autostart": False})
|
|
199
|
+
return {"success": True, "message": "✅ DebtPilot autostart removed."}
|
|
200
|
+
else:
|
|
201
|
+
return {"success": False, "error": "Task not found or could not be removed."}
|
|
202
|
+
except Exception as e:
|
|
203
|
+
return {"success": False, "error": str(e)}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def autostart_status() -> Dict:
|
|
207
|
+
"""Check if autostart is registered."""
|
|
208
|
+
if sys.platform != "win32":
|
|
209
|
+
return {"installed": False, "platform": sys.platform}
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
result = subprocess.run(
|
|
213
|
+
["schtasks", "/Query", "/TN", TASK_NAME, "/FO", "LIST"],
|
|
214
|
+
capture_output=True, text=True, timeout=10
|
|
215
|
+
)
|
|
216
|
+
if result.returncode == 0:
|
|
217
|
+
config = _get_config()
|
|
218
|
+
# Parse next run time from output
|
|
219
|
+
next_run = ""
|
|
220
|
+
for line in result.stdout.splitlines():
|
|
221
|
+
if "Next Run Time" in line:
|
|
222
|
+
next_run = line.split(":", 1)[-1].strip()
|
|
223
|
+
return {
|
|
224
|
+
"installed": True,
|
|
225
|
+
"task_name": TASK_NAME,
|
|
226
|
+
"project_dir": config.get("watched_project", "unknown"),
|
|
227
|
+
"port": config.get("daemon_port", 57017),
|
|
228
|
+
"next_run": next_run,
|
|
229
|
+
}
|
|
230
|
+
else:
|
|
231
|
+
return {"installed": False}
|
|
232
|
+
except Exception as e:
|
|
233
|
+
return {"installed": False, "error": str(e)}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def run_now(project_dir: Optional[str] = None, port: int = 57017) -> Dict:
|
|
237
|
+
"""Start the task immediately via Task Scheduler."""
|
|
238
|
+
if sys.platform != "win32":
|
|
239
|
+
return {"success": False, "error": "Not on Windows"}
|
|
240
|
+
try:
|
|
241
|
+
result = subprocess.run(
|
|
242
|
+
["schtasks", "/Run", "/TN", TASK_NAME],
|
|
243
|
+
capture_output=True, text=True, timeout=10
|
|
244
|
+
)
|
|
245
|
+
return {
|
|
246
|
+
"success": result.returncode == 0,
|
|
247
|
+
"message": "Daemon started." if result.returncode == 0 else result.stderr
|
|
248
|
+
}
|
|
249
|
+
except Exception as e:
|
|
250
|
+
return {"success": False, "error": str(e)}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def set_watched_project(project_dir: str) -> None:
|
|
254
|
+
"""Update which project directory the daemon watches."""
|
|
255
|
+
_save_config({"watched_project": str(Path(project_dir).resolve())})
|