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.
Files changed (80) hide show
  1. devcoach-0.3.6/.github/scripts/fixtures/devcoach-backup.zip +0 -0
  2. devcoach-0.3.6/.github/scripts/take_screenshots.py +110 -0
  3. {devcoach-0.3.5 → devcoach-0.3.6}/.github/workflows/ci.yml +73 -5
  4. devcoach-0.3.6/.github/workflows/update-screenshots.yml +39 -0
  5. {devcoach-0.3.5 → devcoach-0.3.6}/PKG-INFO +12 -1
  6. {devcoach-0.3.5 → devcoach-0.3.6}/README.md +11 -0
  7. devcoach-0.3.6/docs/favicon.svg +1 -0
  8. devcoach-0.3.6/docs/index.md +55 -0
  9. devcoach-0.3.6/docs/screenshots/knowledge-map-dark.png +0 -0
  10. devcoach-0.3.6/docs/screenshots/knowledge-map-light.png +0 -0
  11. devcoach-0.3.6/docs/screenshots/lessons-dark.png +0 -0
  12. devcoach-0.3.6/docs/screenshots/lessons-light.png +0 -0
  13. devcoach-0.3.6/docs/screenshots/settings-dark.png +0 -0
  14. devcoach-0.3.6/docs/screenshots/settings-light.png +0 -0
  15. devcoach-0.3.6/mkdocs.yml +54 -0
  16. {devcoach-0.3.5 → devcoach-0.3.6}/pyproject.toml +13 -1
  17. devcoach-0.3.6/sonar-project.properties +11 -0
  18. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/core/db.py +17 -2
  19. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/core/models.py +1 -0
  20. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/app.py +31 -4
  21. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/templates/base.html +16 -5
  22. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/templates/lessons.html +30 -28
  23. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/templates/profile.html +8 -8
  24. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/templates/settings.html +26 -4
  25. devcoach-0.3.6/tests/test_cli_commands.py +514 -0
  26. devcoach-0.3.6/tests/test_coach.py +159 -0
  27. devcoach-0.3.6/tests/test_db_extra.py +413 -0
  28. devcoach-0.3.6/tests/test_detect.py +111 -0
  29. devcoach-0.3.6/tests/test_git.py +140 -0
  30. devcoach-0.3.6/tests/test_mcp_server.py +532 -0
  31. devcoach-0.3.6/tests/test_prompts.py +126 -0
  32. devcoach-0.3.6/tests/test_web_extra.py +388 -0
  33. {devcoach-0.3.5 → devcoach-0.3.6}/uv.lock +509 -1
  34. {devcoach-0.3.5 → devcoach-0.3.6}/.github/dependabot.yml +0 -0
  35. {devcoach-0.3.5 → devcoach-0.3.6}/.github/workflows/ruff-autofix.yml +0 -0
  36. {devcoach-0.3.5 → devcoach-0.3.6}/.gitignore +0 -0
  37. {devcoach-0.3.5 → devcoach-0.3.6}/CLAUDE.md +0 -0
  38. {devcoach-0.3.5 → devcoach-0.3.6}/LICENSE +0 -0
  39. {devcoach-0.3.5 → devcoach-0.3.6}/NOTICE +0 -0
  40. {devcoach-0.3.5 → devcoach-0.3.6}/SKILL.md +0 -0
  41. {devcoach-0.3.5 → devcoach-0.3.6}/docs/PLAN.md +0 -0
  42. {devcoach-0.3.5 → devcoach-0.3.6}/docs/cli.md +0 -0
  43. {devcoach-0.3.5 → devcoach-0.3.6}/docs/configuration.md +0 -0
  44. {devcoach-0.3.5 → devcoach-0.3.6}/docs/getting-started.md +0 -0
  45. {devcoach-0.3.5 → devcoach-0.3.6}/docs/mcp-server.md +0 -0
  46. {devcoach-0.3.5 → devcoach-0.3.6}/docs/web-ui.md +0 -0
  47. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/SKILL.md +0 -0
  48. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/__init__.py +0 -0
  49. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/cli/__init__.py +0 -0
  50. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/cli/commands.py +0 -0
  51. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/core/__init__.py +0 -0
  52. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/core/coach.py +0 -0
  53. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/core/detect.py +0 -0
  54. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/core/git.py +0 -0
  55. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/core/prompts.py +0 -0
  56. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/mcp/__init__.py +0 -0
  57. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/mcp/server.py +0 -0
  58. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/__init__.py +0 -0
  59. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/favicon.svg +0 -0
  60. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/relative-time.js +0 -0
  61. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/style.css +0 -0
  62. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/alpinejs.min.js +0 -0
  63. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/flatpickr-dark.min.css +0 -0
  64. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/flatpickr.min.css +0 -0
  65. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/flatpickr.min.js +0 -0
  66. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/highlight.min.js +0 -0
  67. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/hljs-dark.min.css +0 -0
  68. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/hljs-light.min.css +0 -0
  69. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/htmx.min.js +0 -0
  70. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/icons/bitbucket.svg +0 -0
  71. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/icons/github.svg +0 -0
  72. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/icons/gitlab.svg +0 -0
  73. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/icons/vscode.svg +0 -0
  74. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/marked.min.js +0 -0
  75. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/static/vendor/tailwind.js +0 -0
  76. {devcoach-0.3.5 → devcoach-0.3.6}/src/devcoach/web/templates/lesson_detail.html +0 -0
  77. {devcoach-0.3.5 → devcoach-0.3.6}/tests/__init__.py +0 -0
  78. {devcoach-0.3.5 → devcoach-0.3.6}/tests/conftest.py +0 -0
  79. {devcoach-0.3.5 → devcoach-0.3.6}/tests/test_cli.py +0 -0
  80. {devcoach-0.3.5 → devcoach-0.3.6}/tests/test_web.py +0 -0
@@ -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: sed -i 's/^version = ".*"/version = "${{ steps.ver.outputs.version }}"/' pyproject.toml
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
- - run: uv run pytest tests/ -v --tb=short
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.5
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
  [![PyPI](https://img.shields.io/github/v/release/UltimaPhoenix/dev-coach?label=PyPI)](https://pypi.org/project/devcoach/)
236
236
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://pypi.org/project/devcoach/)
237
237
  [![CI](https://github.com/UltimaPhoenix/dev-coach/actions/workflows/ci.yml/badge.svg)](https://github.com/UltimaPhoenix/dev-coach/actions/workflows/ci.yml)
238
+ [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=UltimaPhoenix_dev-coach&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=UltimaPhoenix_dev-coach)
239
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=UltimaPhoenix_dev-coach&metric=coverage)](https://sonarcloud.io/summary/new_code?id=UltimaPhoenix_dev-coach)
240
+ [![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-purple)](https://ultimaphoenix.github.io/dev-coach/)
238
241
  [![License](https://img.shields.io/badge/license-Apache%202.0-blue)](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
+ | ![Knowledge map](docs/screenshots/knowledge-map-light.png) | ![Lessons](docs/screenshots/lessons-dark.png) | ![Settings](docs/screenshots/settings-dark.png) |
265
+
266
+ ---
267
+
257
268
  ## Installation
258
269
 
259
270
  ### Recommended — no permanent install needed
@@ -3,6 +3,9 @@
3
3
  [![PyPI](https://img.shields.io/github/v/release/UltimaPhoenix/dev-coach?label=PyPI)](https://pypi.org/project/devcoach/)
4
4
  [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://pypi.org/project/devcoach/)
5
5
  [![CI](https://github.com/UltimaPhoenix/dev-coach/actions/workflows/ci.yml/badge.svg)](https://github.com/UltimaPhoenix/dev-coach/actions/workflows/ci.yml)
6
+ [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=UltimaPhoenix_dev-coach&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=UltimaPhoenix_dev-coach)
7
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=UltimaPhoenix_dev-coach&metric=coverage)](https://sonarcloud.io/summary/new_code?id=UltimaPhoenix_dev-coach)
8
+ [![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-purple)](https://ultimaphoenix.github.io/dev-coach/)
6
9
  [![License](https://img.shields.io/badge/license-Apache%202.0-blue)](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
+ | ![Knowledge map](docs/screenshots/knowledge-map-light.png) | ![Lessons](docs/screenshots/lessons-dark.png) | ![Settings](docs/screenshots/settings-dark.png) |
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
+ ![Knowledge map – dark theme](screenshots/knowledge-map-dark.png)
26
+
27
+ === "Light"
28
+ ![Knowledge map – light theme](screenshots/knowledge-map-light.png)
29
+
30
+ ### Lesson history
31
+
32
+ === "Dark"
33
+ ![Lessons – dark theme](screenshots/lessons-dark.png)
34
+
35
+ === "Light"
36
+ ![Lessons – light theme](screenshots/lessons-light.png)
37
+
38
+ ### Settings
39
+
40
+ === "Dark"
41
+ ![Settings – dark theme](screenshots/settings-dark.png)
42
+
43
+ === "Light"
44
+ ![Settings – light theme](screenshots/settings-dark.png)
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.
@@ -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.5"
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/*"]
@@ -0,0 +1,11 @@
1
+ sonar.projectKey=UltimaPhoenix_dev-coach
2
+ sonar.organization=ultimaphoenix
3
+
4
+ sonar.projectName=devcoach
5
+ sonar.projectVersion=0.3.6
6
+
7
+ sonar.sources=src
8
+ sonar.tests=tests
9
+ sonar.python.version=3.11
10
+
11
+ sonar.python.coverage.reportPaths=coverage.xml
@@ -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
@@ -88,6 +88,7 @@ class Settings(BaseModel):
88
88
 
89
89
  max_per_day: int = 2
90
90
  min_gap_minutes: int = 240 # replaces min_hours_between (4h default)
91
+ ui_theme: Literal["system", "dark", "light"] = "system"
91
92
 
92
93
 
93
94
  class KnowledgeUpdate(BaseModel):
@@ -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
- records = json.loads(content)
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