devcoach 0.3.5__tar.gz → 0.3.6__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.
- devcoach-0.3.6/.github/scripts/fixtures/devcoach-backup.zip +0 -0
- devcoach-0.3.6/.github/scripts/take_screenshots.py +110 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/.github/workflows/ci.yml +73 -5
- devcoach-0.3.6/.github/workflows/update-screenshots.yml +39 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/PKG-INFO +12 -1
- {devcoach-0.3.5 → devcoach-0.3.6}/README.md +11 -0
- devcoach-0.3.6/docs/favicon.svg +1 -0
- devcoach-0.3.6/docs/index.md +55 -0
- devcoach-0.3.6/docs/screenshots/knowledge-map-dark.png +0 -0
- devcoach-0.3.6/docs/screenshots/knowledge-map-light.png +0 -0
- devcoach-0.3.6/docs/screenshots/lessons-dark.png +0 -0
- devcoach-0.3.6/docs/screenshots/lessons-light.png +0 -0
- devcoach-0.3.6/docs/screenshots/settings-dark.png +0 -0
- devcoach-0.3.6/docs/screenshots/settings-light.png +0 -0
- devcoach-0.3.6/mkdocs.yml +54 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/pyproject.toml +13 -1
- devcoach-0.3.6/sonar-project.properties +11 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/core/db.py +17 -2
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/core/models.py +1 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/app.py +31 -4
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/templates/base.html +16 -5
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/templates/lessons.html +30 -28
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/templates/profile.html +8 -8
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/templates/settings.html +26 -4
- devcoach-0.3.6/tests/test_cli_commands.py +514 -0
- devcoach-0.3.6/tests/test_coach.py +159 -0
- devcoach-0.3.6/tests/test_db_extra.py +413 -0
- devcoach-0.3.6/tests/test_detect.py +111 -0
- devcoach-0.3.6/tests/test_git.py +140 -0
- devcoach-0.3.6/tests/test_mcp_server.py +532 -0
- devcoach-0.3.6/tests/test_prompts.py +126 -0
- devcoach-0.3.6/tests/test_web_extra.py +388 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/uv.lock +509 -1
- {devcoach-0.3.5 → devcoach-0.3.6}/.github/dependabot.yml +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/.github/workflows/ruff-autofix.yml +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/.gitignore +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/CLAUDE.md +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/LICENSE +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/NOTICE +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/SKILL.md +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/docs/PLAN.md +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/docs/cli.md +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/docs/configuration.md +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/docs/getting-started.md +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/docs/mcp-server.md +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/docs/web-ui.md +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/SKILL.md +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/__init__.py +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/cli/__init__.py +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/cli/commands.py +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/core/__init__.py +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/core/coach.py +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/core/detect.py +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/core/git.py +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/core/prompts.py +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/mcp/__init__.py +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/mcp/server.py +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/__init__.py +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/favicon.svg +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/relative-time.js +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/style.css +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/alpinejs.min.js +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/flatpickr-dark.min.css +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/flatpickr.min.css +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/flatpickr.min.js +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/highlight.min.js +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/hljs-dark.min.css +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/hljs-light.min.css +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/htmx.min.js +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/icons/bitbucket.svg +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/icons/github.svg +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/icons/gitlab.svg +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/icons/vscode.svg +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/marked.min.js +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/tailwind.js +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/templates/lesson_detail.html +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/tests/__init__.py +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/tests/conftest.py +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/tests/test_cli.py +0 -0
- {devcoach-0.3.5 → devcoach-0.3.6}/tests/test_web.py +0 -0
|
Binary file
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Take devcoach UI screenshots for documentation.
|
|
2
|
+
|
|
3
|
+
Flow:
|
|
4
|
+
1. Restore DB from .github/scripts/fixtures/devcoach-backup.zip via `devcoach restore`
|
|
5
|
+
2. Start `devcoach ui` on a fixed port
|
|
6
|
+
3. Capture light + dark screenshots of each page
|
|
7
|
+
4. Stop the server
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
SCREENSHOTS_DIR = Path("docs/screenshots")
|
|
18
|
+
FIXTURES_DIR = Path(".github/scripts/fixtures")
|
|
19
|
+
BACKUP_ZIP = FIXTURES_DIR / "devcoach-backup.zip"
|
|
20
|
+
PORT = 7862
|
|
21
|
+
BASE_URL = f"http://localhost:{PORT}"
|
|
22
|
+
VIEWPORT = {"width": 1440, "height": 900}
|
|
23
|
+
|
|
24
|
+
PAGES = [
|
|
25
|
+
("knowledge-map", "/"),
|
|
26
|
+
("lessons", "/lessons"),
|
|
27
|
+
("settings", "/settings"),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
def restore_db() -> None:
|
|
31
|
+
result = subprocess.run(
|
|
32
|
+
["devcoach", "restore", str(BACKUP_ZIP)],
|
|
33
|
+
check=True,
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
)
|
|
37
|
+
print(result.stdout.strip())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def wait_for_server(url: str, timeout: int = 30) -> None:
|
|
41
|
+
for _ in range(timeout):
|
|
42
|
+
try:
|
|
43
|
+
urllib.request.urlopen(url, timeout=1)
|
|
44
|
+
return
|
|
45
|
+
except Exception:
|
|
46
|
+
time.sleep(1)
|
|
47
|
+
raise TimeoutError(f"Server at {url} did not start within {timeout}s")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def take_screenshots(server_proc: subprocess.Popen) -> None:
|
|
51
|
+
from playwright.sync_api import sync_playwright
|
|
52
|
+
|
|
53
|
+
SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
with sync_playwright() as pw:
|
|
56
|
+
browser = pw.chromium.launch()
|
|
57
|
+
|
|
58
|
+
ctx = browser.new_context(viewport=VIEWPORT, color_scheme="light")
|
|
59
|
+
page = ctx.new_page()
|
|
60
|
+
for name, path in PAGES:
|
|
61
|
+
page.goto(f"{BASE_URL}{path}")
|
|
62
|
+
page.wait_for_load_state("networkidle")
|
|
63
|
+
out = SCREENSHOTS_DIR / f"{name}-light.png"
|
|
64
|
+
page.screenshot(path=str(out))
|
|
65
|
+
print(f" saved {out}")
|
|
66
|
+
ctx.close()
|
|
67
|
+
|
|
68
|
+
ctx = browser.new_context(viewport=VIEWPORT, color_scheme="dark")
|
|
69
|
+
page = ctx.new_page()
|
|
70
|
+
for name, path in PAGES:
|
|
71
|
+
page.goto(f"{BASE_URL}{path}")
|
|
72
|
+
page.wait_for_load_state("networkidle")
|
|
73
|
+
out = SCREENSHOTS_DIR / f"{name}-dark.png"
|
|
74
|
+
page.screenshot(path=str(out))
|
|
75
|
+
print(f" saved {out}")
|
|
76
|
+
ctx.close()
|
|
77
|
+
|
|
78
|
+
browser.close()
|
|
79
|
+
|
|
80
|
+
server_proc.send_signal(signal.SIGTERM)
|
|
81
|
+
server_proc.wait(timeout=10)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main() -> None:
|
|
85
|
+
if not BACKUP_ZIP.exists():
|
|
86
|
+
sys.exit(f"Backup not found: {BACKUP_ZIP}")
|
|
87
|
+
|
|
88
|
+
print(f"Restoring DB from {BACKUP_ZIP}…")
|
|
89
|
+
restore_db()
|
|
90
|
+
|
|
91
|
+
print(f"Starting devcoach UI on port {PORT}…")
|
|
92
|
+
proc = subprocess.Popen(
|
|
93
|
+
["devcoach", "ui", "--port", str(PORT)],
|
|
94
|
+
stdout=subprocess.DEVNULL,
|
|
95
|
+
stderr=subprocess.DEVNULL,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
wait_for_server(BASE_URL)
|
|
100
|
+
print("Taking screenshots…")
|
|
101
|
+
take_screenshots(proc)
|
|
102
|
+
except Exception:
|
|
103
|
+
proc.terminate()
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
print("Done.")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
main()
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
name: CI
|
|
2
2
|
|
|
3
|
+
run-name: >-
|
|
4
|
+
${{ github.event_name == 'workflow_dispatch'
|
|
5
|
+
&& format('CI — New release ({0})', inputs.version != '' && inputs.version || inputs.bump)
|
|
6
|
+
|| (startsWith(github.ref, 'refs/tags/v')
|
|
7
|
+
&& format('CI — New release ({0})', github.ref_name)
|
|
8
|
+
|| (github.event_name == 'pull_request'
|
|
9
|
+
&& format('CI - PR {0}', github.event.pull_request.title)
|
|
10
|
+
|| format('CI — {0}', github.event.head_commit.message))) }}
|
|
11
|
+
|
|
3
12
|
# Covers three scenarios:
|
|
4
13
|
#
|
|
5
|
-
# 1. Push to main / pull request → lint + test + build
|
|
14
|
+
# 1. Push to main / pull request → lint + test + build + sonar + pages
|
|
6
15
|
# 2. Tag push (v*) → lint + test + build + publish + GitHub Release
|
|
7
16
|
# 3. workflow_dispatch (Release) → bump version → tag → lint + test + build + publish + GitHub Release
|
|
8
17
|
#
|
|
@@ -70,12 +79,14 @@ jobs:
|
|
|
70
79
|
echo "version=$NEXT" >> "$GITHUB_OUTPUT"
|
|
71
80
|
echo "tag=v$NEXT" >> "$GITHUB_OUTPUT"
|
|
72
81
|
|
|
73
|
-
- name: Bump version in pyproject.toml
|
|
74
|
-
run:
|
|
82
|
+
- name: Bump version in pyproject.toml and sonar-project.properties
|
|
83
|
+
run: |
|
|
84
|
+
sed -i 's/^version = ".*"/version = "${{ steps.ver.outputs.version }}"/' pyproject.toml
|
|
85
|
+
sed -i 's/^sonar.projectVersion=.*/sonar.projectVersion=${{ steps.ver.outputs.version }}/' sonar-project.properties
|
|
75
86
|
|
|
76
87
|
- name: Commit and tag
|
|
77
88
|
run: |
|
|
78
|
-
git add pyproject.toml
|
|
89
|
+
git add pyproject.toml sonar-project.properties
|
|
79
90
|
git commit -m "chore: bump version to ${{ steps.ver.outputs.version }}"
|
|
80
91
|
git tag "${{ steps.ver.outputs.tag }}"
|
|
81
92
|
git push --atomic origin main "${{ steps.ver.outputs.tag }}"
|
|
@@ -115,7 +126,8 @@ jobs:
|
|
|
115
126
|
with:
|
|
116
127
|
python-version: ${{ matrix.python-version }}
|
|
117
128
|
- run: uv sync --group dev
|
|
118
|
-
-
|
|
129
|
+
- name: Run tests
|
|
130
|
+
run: uv run pytest tests/ -v --tb=short
|
|
119
131
|
|
|
120
132
|
# ── Build ───────────────────────────────────────────────────────────────
|
|
121
133
|
build:
|
|
@@ -137,6 +149,62 @@ jobs:
|
|
|
137
149
|
path: dist/
|
|
138
150
|
retention-days: 7
|
|
139
151
|
|
|
152
|
+
# ── SonarCloud scan + quality gate ─────────────────────────────────────
|
|
153
|
+
# CI-based analysis: feeds coverage.xml to SonarCloud and blocks PR merge
|
|
154
|
+
# if the quality gate fails. Requires Automatic Analysis to be DISABLED on
|
|
155
|
+
# sonarcloud.io (Administration → Analysis Method).
|
|
156
|
+
sonar:
|
|
157
|
+
name: SonarCloud scan
|
|
158
|
+
needs: [bump, test]
|
|
159
|
+
if: always() && (needs.bump.result == 'success' || needs.bump.result == 'skipped') && needs.test.result == 'success'
|
|
160
|
+
runs-on: ubuntu-latest
|
|
161
|
+
steps:
|
|
162
|
+
- uses: actions/checkout@v6
|
|
163
|
+
with:
|
|
164
|
+
ref: ${{ needs.bump.outputs.tag || github.ref }}
|
|
165
|
+
fetch-depth: 0
|
|
166
|
+
- uses: astral-sh/setup-uv@v7
|
|
167
|
+
with:
|
|
168
|
+
python-version: "3.12"
|
|
169
|
+
- run: uv sync --group dev
|
|
170
|
+
- name: Run tests with coverage
|
|
171
|
+
run: uv run pytest tests/ -v --tb=short --cov=src/devcoach --cov-report=xml
|
|
172
|
+
- uses: sonarsource/sonarqube-scan-action@v6
|
|
173
|
+
env:
|
|
174
|
+
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
|
175
|
+
- uses: sonarsource/sonarqube-quality-gate-action@v1
|
|
176
|
+
if: github.event_name == 'pull_request'
|
|
177
|
+
timeout-minutes: 5
|
|
178
|
+
env:
|
|
179
|
+
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
|
180
|
+
|
|
181
|
+
# ── GitHub Pages ────────────────────────────────────────────────────────
|
|
182
|
+
# Builds MkDocs site and deploys to GitHub Pages on every push to main.
|
|
183
|
+
pages:
|
|
184
|
+
name: Deploy docs to GitHub Pages
|
|
185
|
+
needs: [bump, build]
|
|
186
|
+
if: always() && needs.build.result == 'success' && github.ref == 'refs/heads/main' && github.event_name == 'push'
|
|
187
|
+
runs-on: ubuntu-latest
|
|
188
|
+
permissions:
|
|
189
|
+
pages: write
|
|
190
|
+
id-token: write
|
|
191
|
+
environment:
|
|
192
|
+
name: github-pages
|
|
193
|
+
url: ${{ steps.deploy.outputs.page_url }}
|
|
194
|
+
steps:
|
|
195
|
+
- uses: actions/checkout@v6
|
|
196
|
+
- uses: astral-sh/setup-uv@v7
|
|
197
|
+
with:
|
|
198
|
+
python-version: "3.12"
|
|
199
|
+
- run: uv sync --group docs
|
|
200
|
+
- run: uv run mkdocs build
|
|
201
|
+
- uses: actions/upload-pages-artifact@v3
|
|
202
|
+
with:
|
|
203
|
+
path: site/
|
|
204
|
+
- name: Deploy to GitHub Pages
|
|
205
|
+
id: deploy
|
|
206
|
+
uses: actions/deploy-pages@v4
|
|
207
|
+
|
|
140
208
|
# ── Publish ─────────────────────────────────────────────────────────────
|
|
141
209
|
# Runs when a tag is present: either created by the bump job (workflow_dispatch)
|
|
142
210
|
# or pushed directly (git push origin v1.2.3).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: Update documentation screenshots (GitHub Pages)
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
screenshots:
|
|
8
|
+
name: Take and commit screenshots
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
contents: write
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v6
|
|
15
|
+
|
|
16
|
+
- uses: astral-sh/setup-uv@v7
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.12"
|
|
19
|
+
|
|
20
|
+
- name: Install devcoach
|
|
21
|
+
run: uv sync
|
|
22
|
+
|
|
23
|
+
- name: Install Playwright + Chromium
|
|
24
|
+
run: |
|
|
25
|
+
uv pip install playwright
|
|
26
|
+
uv run playwright install chromium --with-deps
|
|
27
|
+
|
|
28
|
+
- name: Restore DB and take screenshots
|
|
29
|
+
run: uv run python .github/scripts/take_screenshots.py
|
|
30
|
+
|
|
31
|
+
- name: Commit screenshots
|
|
32
|
+
run: |
|
|
33
|
+
git config user.name "github-actions[bot]"
|
|
34
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
35
|
+
git add docs/screenshots/
|
|
36
|
+
git diff --staged --quiet && echo "No changes." || (
|
|
37
|
+
git commit -m "docs: update screenshots [skip ci]" &&
|
|
38
|
+
git push
|
|
39
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devcoach
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.6
|
|
4
4
|
Summary: A local MCP server that acts as a progressive technical coach for Claude Code and Claude Desktop
|
|
5
5
|
Project-URL: Homepage, https://github.com/UltimaPhoenix/dev-coach
|
|
6
6
|
Project-URL: Repository, https://github.com/UltimaPhoenix/dev-coach
|
|
@@ -235,6 +235,9 @@ Description-Content-Type: text/markdown
|
|
|
235
235
|
[](https://pypi.org/project/devcoach/)
|
|
236
236
|
[](https://pypi.org/project/devcoach/)
|
|
237
237
|
[](https://github.com/UltimaPhoenix/dev-coach/actions/workflows/ci.yml)
|
|
238
|
+
[](https://sonarcloud.io/summary/new_code?id=UltimaPhoenix_dev-coach)
|
|
239
|
+
[](https://sonarcloud.io/summary/new_code?id=UltimaPhoenix_dev-coach)
|
|
240
|
+
[](https://ultimaphoenix.github.io/dev-coach/)
|
|
238
241
|
[](LICENSE)
|
|
239
242
|
|
|
240
243
|
**Progressive technical coaching, directly in Claude.** After every task you complete with Claude Code or Claude Desktop, devcoach delivers a short, targeted lesson based on what you already know — no generic tutorials, no repeated topics.
|
|
@@ -254,6 +257,14 @@ Everything runs **locally**. No data leaves your machine. One SQLite file at `~/
|
|
|
254
257
|
|
|
255
258
|
---
|
|
256
259
|
|
|
260
|
+
## Screenshots
|
|
261
|
+
|
|
262
|
+
| Knowledge map | Lesson history | Settings |
|
|
263
|
+
|:---:|:---:|:---:|
|
|
264
|
+
|  |  |  |
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
257
268
|
## Installation
|
|
258
269
|
|
|
259
270
|
### Recommended — no permanent install needed
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
[](https://pypi.org/project/devcoach/)
|
|
4
4
|
[](https://pypi.org/project/devcoach/)
|
|
5
5
|
[](https://github.com/UltimaPhoenix/dev-coach/actions/workflows/ci.yml)
|
|
6
|
+
[](https://sonarcloud.io/summary/new_code?id=UltimaPhoenix_dev-coach)
|
|
7
|
+
[](https://sonarcloud.io/summary/new_code?id=UltimaPhoenix_dev-coach)
|
|
8
|
+
[](https://ultimaphoenix.github.io/dev-coach/)
|
|
6
9
|
[](LICENSE)
|
|
7
10
|
|
|
8
11
|
**Progressive technical coaching, directly in Claude.** After every task you complete with Claude Code or Claude Desktop, devcoach delivers a short, targeted lesson based on what you already know — no generic tutorials, no repeated topics.
|
|
@@ -22,6 +25,14 @@ Everything runs **locally**. No data leaves your machine. One SQLite file at `~/
|
|
|
22
25
|
|
|
23
26
|
---
|
|
24
27
|
|
|
28
|
+
## Screenshots
|
|
29
|
+
|
|
30
|
+
| Knowledge map | Lesson history | Settings |
|
|
31
|
+
|:---:|:---:|:---:|
|
|
32
|
+
|  |  |  |
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
25
36
|
## Installation
|
|
26
37
|
|
|
27
38
|
### Recommended — no permanent install needed
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
../src/devcoach/web/static/favicon.svg
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# devcoach
|
|
2
|
+
|
|
3
|
+
**Progressive technical coaching, directly in Claude.** After every task you complete with Claude Code or Claude Desktop, devcoach delivers a short, targeted lesson based on what you already know — no generic tutorials, no repeated topics.
|
|
4
|
+
|
|
5
|
+
Everything runs **locally**. No data leaves your machine. One SQLite file at `~/.devcoach/coaching.db`.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
| Step | What happens |
|
|
12
|
+
|------|-------------|
|
|
13
|
+
| You complete a task with Claude | Claude finishes the work as normal |
|
|
14
|
+
| devcoach checks your knowledge map | Finds a topic where you have room to grow, related to what you just did |
|
|
15
|
+
| A lesson appears at the end of the response | Calibrated to your level (junior / mid / senior), never repeated |
|
|
16
|
+
| You mark it know / don't know | Confidence scores update, shaping future lessons |
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Screenshots
|
|
21
|
+
|
|
22
|
+
### Knowledge map
|
|
23
|
+
|
|
24
|
+
=== "Dark"
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
=== "Light"
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
### Lesson history
|
|
31
|
+
|
|
32
|
+
=== "Dark"
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
=== "Light"
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
### Settings
|
|
39
|
+
|
|
40
|
+
=== "Dark"
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
=== "Light"
|
|
44
|
+

|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Quick install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv tool install devcoach
|
|
52
|
+
devcoach install # registers with Claude Code / Claude Desktop
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Restart Claude and you're ready. See [Getting started](getting-started.md) for the full onboarding walkthrough.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
site_name: devcoach
|
|
2
|
+
site_description: Progressive technical coaching, directly in Claude
|
|
3
|
+
site_url: https://ultimaphoenix.github.io/dev-coach/
|
|
4
|
+
repo_url: https://github.com/UltimaPhoenix/dev-coach
|
|
5
|
+
repo_name: UltimaPhoenix/dev-coach
|
|
6
|
+
edit_uri: edit/main/docs/
|
|
7
|
+
|
|
8
|
+
theme:
|
|
9
|
+
name: material
|
|
10
|
+
favicon: favicon.svg
|
|
11
|
+
logo: favicon.svg
|
|
12
|
+
palette:
|
|
13
|
+
- scheme: slate
|
|
14
|
+
primary: deep purple
|
|
15
|
+
accent: purple
|
|
16
|
+
toggle:
|
|
17
|
+
icon: material/brightness-4
|
|
18
|
+
name: Switch to light mode
|
|
19
|
+
- scheme: default
|
|
20
|
+
primary: deep purple
|
|
21
|
+
accent: purple
|
|
22
|
+
toggle:
|
|
23
|
+
icon: material/brightness-7
|
|
24
|
+
name: Switch to dark mode
|
|
25
|
+
features:
|
|
26
|
+
- navigation.top
|
|
27
|
+
- navigation.indexes
|
|
28
|
+
- search.highlight
|
|
29
|
+
- content.code.copy
|
|
30
|
+
- toc.integrate
|
|
31
|
+
icon:
|
|
32
|
+
repo: fontawesome/brands/github
|
|
33
|
+
|
|
34
|
+
nav:
|
|
35
|
+
- Home: index.md
|
|
36
|
+
- Getting started: getting-started.md
|
|
37
|
+
- CLI reference: cli.md
|
|
38
|
+
- MCP server: mcp-server.md
|
|
39
|
+
- Web UI: web-ui.md
|
|
40
|
+
- Configuration: configuration.md
|
|
41
|
+
|
|
42
|
+
plugins:
|
|
43
|
+
- search
|
|
44
|
+
|
|
45
|
+
markdown_extensions:
|
|
46
|
+
- admonition
|
|
47
|
+
- pymdownx.highlight:
|
|
48
|
+
anchor_linenums: true
|
|
49
|
+
- pymdownx.superfences
|
|
50
|
+
- pymdownx.tabbed:
|
|
51
|
+
alternate_style: true
|
|
52
|
+
- tables
|
|
53
|
+
- attr_list
|
|
54
|
+
- md_in_html
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "devcoach"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.6"
|
|
4
4
|
description = "A local MCP server that acts as a progressive technical coach for Claude Code and Claude Desktop"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -49,9 +49,14 @@ packages = ["src/devcoach"]
|
|
|
49
49
|
[dependency-groups]
|
|
50
50
|
dev = [
|
|
51
51
|
"pytest>=8.0",
|
|
52
|
+
"pytest-cov>=5.0",
|
|
52
53
|
"httpx>=0.27",
|
|
53
54
|
"ruff>=0.4",
|
|
54
55
|
]
|
|
56
|
+
docs = [
|
|
57
|
+
"mkdocs>=1.6",
|
|
58
|
+
"mkdocs-material>=9.5",
|
|
59
|
+
]
|
|
55
60
|
|
|
56
61
|
[tool.ruff]
|
|
57
62
|
line-length = 100
|
|
@@ -63,3 +68,10 @@ ignore = ["E501"]
|
|
|
63
68
|
|
|
64
69
|
[tool.pytest.ini_options]
|
|
65
70
|
testpaths = ["tests"]
|
|
71
|
+
|
|
72
|
+
[tool.coverage.run]
|
|
73
|
+
source = ["src/devcoach"]
|
|
74
|
+
omit = ["tests/*"]
|
|
75
|
+
|
|
76
|
+
[tool.coverage.report]
|
|
77
|
+
omit = ["tests/*"]
|
|
@@ -49,6 +49,7 @@ DEFAULT_SETTINGS: dict[str, str] = {
|
|
|
49
49
|
"max_per_day": "2",
|
|
50
50
|
"min_gap_minutes": "240", # replaces min_hours_between
|
|
51
51
|
"onboarding_completed": "0",
|
|
52
|
+
"ui_theme": "system",
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
# Ordered category → topic list mapping for the knowledge map UI.
|
|
@@ -287,6 +288,9 @@ def count_filtered_lessons(
|
|
|
287
288
|
|
|
288
289
|
|
|
289
290
|
_SORT_COLUMNS = frozenset({"timestamp", "level", "topic_id", "title", "feedback"})
|
|
291
|
+
_METADATA_COLUMNS = frozenset(
|
|
292
|
+
{"project", "repository", "branch", "commit_hash", "repository_platform"}
|
|
293
|
+
)
|
|
290
294
|
|
|
291
295
|
|
|
292
296
|
def get_lessons(
|
|
@@ -475,6 +479,8 @@ def restore_backup_zip(conn: sqlite3.Connection, data: bytes) -> dict[str, int]:
|
|
|
475
479
|
set_setting(conn, "max_per_day", str(s["max_per_day"]))
|
|
476
480
|
if "min_gap_minutes" in s:
|
|
477
481
|
set_setting(conn, "min_gap_minutes", str(s["min_gap_minutes"]))
|
|
482
|
+
if s.get("ui_theme") in ("system", "dark", "light"):
|
|
483
|
+
set_setting(conn, "ui_theme", s["ui_theme"])
|
|
478
484
|
result["settings"] = 1
|
|
479
485
|
|
|
480
486
|
if "knowledge.json" in names:
|
|
@@ -518,8 +524,10 @@ def restore_backup_zip(conn: sqlite3.Connection, data: bytes) -> dict[str, int]:
|
|
|
518
524
|
|
|
519
525
|
def get_distinct_column(conn: sqlite3.Connection, column: str) -> list[str]:
|
|
520
526
|
"""Return sorted distinct non-null values for a metadata column."""
|
|
527
|
+
if column not in _METADATA_COLUMNS:
|
|
528
|
+
raise ValueError(f"Column not allowed: {column!r}")
|
|
521
529
|
rows = conn.execute(
|
|
522
|
-
f"SELECT DISTINCT {column} FROM lessons WHERE {column} IS NOT NULL ORDER BY {column}"
|
|
530
|
+
f"SELECT DISTINCT {column} FROM lessons WHERE {column} IS NOT NULL ORDER BY {column}" # noqa: S608
|
|
523
531
|
).fetchall()
|
|
524
532
|
return [row[0] for row in rows]
|
|
525
533
|
|
|
@@ -684,9 +692,12 @@ def get_settings(conn: sqlite3.Connection) -> Settings:
|
|
|
684
692
|
gap = int(data["min_hours_between"]) * 60 # migrate hours → minutes
|
|
685
693
|
else:
|
|
686
694
|
gap = int(DEFAULT_SETTINGS["min_gap_minutes"])
|
|
695
|
+
raw_theme = data.get("ui_theme", DEFAULT_SETTINGS["ui_theme"])
|
|
696
|
+
theme = raw_theme if raw_theme in ("system", "dark", "light") else "system"
|
|
687
697
|
return Settings(
|
|
688
698
|
max_per_day=int(data.get("max_per_day", DEFAULT_SETTINGS["max_per_day"])),
|
|
689
699
|
min_gap_minutes=gap,
|
|
700
|
+
ui_theme=theme, # type: ignore[arg-type]
|
|
690
701
|
)
|
|
691
702
|
|
|
692
703
|
|
|
@@ -715,8 +726,12 @@ def get_usage_defaults(conn: sqlite3.Connection) -> dict[str, str | None]:
|
|
|
715
726
|
"""
|
|
716
727
|
result: dict[str, str | None] = {}
|
|
717
728
|
for col in ("project", "repository", "branch", "repository_platform"):
|
|
729
|
+
# col is from a hardcoded tuple above — allowlist-check is a belt-and-suspenders guard
|
|
730
|
+
if col not in _METADATA_COLUMNS:
|
|
731
|
+
result[col] = None
|
|
732
|
+
continue
|
|
718
733
|
row = conn.execute(
|
|
719
|
-
f"SELECT {col}, COUNT(*) c FROM lessons "
|
|
734
|
+
f"SELECT {col}, COUNT(*) c FROM lessons " # noqa: S608
|
|
720
735
|
f"WHERE {col} IS NOT NULL GROUP BY {col} ORDER BY c DESC LIMIT 1"
|
|
721
736
|
).fetchone()
|
|
722
737
|
result[col] = row[0] if row else None
|
|
@@ -16,6 +16,21 @@ from devcoach.core.models import KnowledgeEntry
|
|
|
16
16
|
app = FastAPI(title="devcoach", docs_url=None, redoc_url=None)
|
|
17
17
|
|
|
18
18
|
_HERE = Path(__file__).parent
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _safe_redirect(url: str, default: str = "/lessons") -> str:
|
|
22
|
+
"""Return url only if it is a safe relative path; otherwise return default.
|
|
23
|
+
|
|
24
|
+
Prevents open-redirect attacks where a form-supplied ``next`` parameter
|
|
25
|
+
could point to an external site (e.g. ``next=https://evil.com``).
|
|
26
|
+
A valid relative path starts with ``/`` but not ``//`` (which browsers
|
|
27
|
+
treat as protocol-relative, i.e. an external URL).
|
|
28
|
+
"""
|
|
29
|
+
if url.startswith("/") and not url.startswith("//"):
|
|
30
|
+
return url
|
|
31
|
+
return default
|
|
32
|
+
|
|
33
|
+
|
|
19
34
|
templates = Jinja2Templates(directory=str(_HERE / "templates"))
|
|
20
35
|
app.mount("/static", StaticFiles(directory=str(_HERE / "static")), name="static")
|
|
21
36
|
|
|
@@ -124,7 +139,12 @@ async def export_lessons_route() -> Response:
|
|
|
124
139
|
@app.post("/lessons/import")
|
|
125
140
|
async def import_lessons_route(file: UploadFile = File(...)) -> RedirectResponse:
|
|
126
141
|
content = await file.read()
|
|
127
|
-
|
|
142
|
+
try:
|
|
143
|
+
records = json.loads(content)
|
|
144
|
+
except (json.JSONDecodeError, ValueError):
|
|
145
|
+
return RedirectResponse(url="/settings?imported=0&skipped=0&invalid=1", status_code=303)
|
|
146
|
+
if not isinstance(records, list):
|
|
147
|
+
return RedirectResponse(url="/settings?imported=0&skipped=0&invalid=1", status_code=303)
|
|
128
148
|
with db.connection() as conn:
|
|
129
149
|
inserted, duplicated, invalid = db.import_lessons(conn, records)
|
|
130
150
|
return RedirectResponse(
|
|
@@ -189,6 +209,7 @@ async def lessons_page(
|
|
|
189
209
|
all_repositories = db.get_distinct_column(conn, "repository")
|
|
190
210
|
all_branches = db.get_distinct_column(conn, "branch")
|
|
191
211
|
all_commits = db.get_distinct_column(conn, "commit_hash")
|
|
212
|
+
settings = db.get_settings(conn)
|
|
192
213
|
|
|
193
214
|
import math
|
|
194
215
|
|
|
@@ -223,6 +244,7 @@ async def lessons_page(
|
|
|
223
244
|
"per_page": _PER_PAGE,
|
|
224
245
|
"total": total,
|
|
225
246
|
"total_pages": total_pages,
|
|
247
|
+
"settings": settings,
|
|
226
248
|
},
|
|
227
249
|
)
|
|
228
250
|
|
|
@@ -231,7 +253,7 @@ async def lessons_page(
|
|
|
231
253
|
async def star_lesson(lesson_id: str, next: str = Form(default="/lessons")) -> RedirectResponse:
|
|
232
254
|
with db.connection() as conn:
|
|
233
255
|
db.toggle_star(conn, lesson_id)
|
|
234
|
-
return RedirectResponse(url=next, status_code=303)
|
|
256
|
+
return RedirectResponse(url=_safe_redirect(next), status_code=303)
|
|
235
257
|
|
|
236
258
|
|
|
237
259
|
@app.post("/lessons/{lesson_id}/feedback")
|
|
@@ -243,19 +265,20 @@ async def submit_feedback(
|
|
|
243
265
|
feedback_value = None if feedback in ("", "clear") else feedback
|
|
244
266
|
with db.connection() as conn:
|
|
245
267
|
coach.record_feedback(conn, lesson_id, feedback_value)
|
|
246
|
-
return RedirectResponse(url=next, status_code=303)
|
|
268
|
+
return RedirectResponse(url=_safe_redirect(next), status_code=303)
|
|
247
269
|
|
|
248
270
|
|
|
249
271
|
@app.get("/lessons/{lesson_id}", response_class=HTMLResponse)
|
|
250
272
|
async def lesson_detail_page(request: Request, lesson_id: str) -> HTMLResponse:
|
|
251
273
|
with db.connection() as conn:
|
|
252
274
|
lesson = db.get_lesson_by_id(conn, lesson_id)
|
|
275
|
+
settings = db.get_settings(conn)
|
|
253
276
|
if lesson is None:
|
|
254
277
|
return HTMLResponse("<h1>Lesson not found</h1>", status_code=404)
|
|
255
278
|
return templates.TemplateResponse(
|
|
256
279
|
request,
|
|
257
280
|
"lesson_detail.html",
|
|
258
|
-
{"lesson": lesson},
|
|
281
|
+
{"lesson": lesson, "settings": settings},
|
|
259
282
|
)
|
|
260
283
|
|
|
261
284
|
|
|
@@ -288,10 +311,14 @@ async def settings_page(
|
|
|
288
311
|
async def update_settings(
|
|
289
312
|
max_per_day: int = Form(...),
|
|
290
313
|
min_gap_minutes: int = Form(...),
|
|
314
|
+
ui_theme: str = Form("system"),
|
|
291
315
|
) -> RedirectResponse:
|
|
316
|
+
if ui_theme not in ("system", "dark", "light"):
|
|
317
|
+
ui_theme = "system"
|
|
292
318
|
with db.connection() as conn:
|
|
293
319
|
db.set_setting(conn, "max_per_day", str(max_per_day))
|
|
294
320
|
db.set_setting(conn, "min_gap_minutes", str(min_gap_minutes))
|
|
321
|
+
db.set_setting(conn, "ui_theme", ui_theme)
|
|
295
322
|
return RedirectResponse(url="/settings", status_code=303)
|
|
296
323
|
|
|
297
324
|
|