devcoach 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.
- devcoach/SKILL.md +288 -0
- devcoach/__init__.py +3 -0
- devcoach/cli/__init__.py +0 -0
- devcoach/cli/commands.py +793 -0
- devcoach/core/__init__.py +0 -0
- devcoach/core/coach.py +141 -0
- devcoach/core/db.py +768 -0
- devcoach/core/detect.py +132 -0
- devcoach/core/git.py +97 -0
- devcoach/core/models.py +104 -0
- devcoach/core/prompts.py +52 -0
- devcoach/mcp/__init__.py +0 -0
- devcoach/mcp/server.py +545 -0
- devcoach/web/__init__.py +0 -0
- devcoach/web/app.py +319 -0
- devcoach/web/static/favicon.svg +3 -0
- devcoach/web/static/relative-time.js +24 -0
- devcoach/web/static/style.css +163 -0
- devcoach/web/static/vendor/alpinejs.min.js +5 -0
- devcoach/web/static/vendor/flatpickr-dark.min.css +795 -0
- devcoach/web/static/vendor/flatpickr.min.css +13 -0
- devcoach/web/static/vendor/flatpickr.min.js +2 -0
- devcoach/web/static/vendor/highlight.min.js +1232 -0
- devcoach/web/static/vendor/hljs-dark.min.css +1 -0
- devcoach/web/static/vendor/hljs-light.min.css +1 -0
- devcoach/web/static/vendor/htmx.min.js +1 -0
- devcoach/web/static/vendor/icons/bitbucket.svg +1 -0
- devcoach/web/static/vendor/icons/github.svg +1 -0
- devcoach/web/static/vendor/icons/gitlab.svg +1 -0
- devcoach/web/static/vendor/icons/vscode.svg +41 -0
- devcoach/web/static/vendor/marked.min.js +6 -0
- devcoach/web/static/vendor/tailwind.js +83 -0
- devcoach/web/templates/base.html +80 -0
- devcoach/web/templates/lesson_detail.html +215 -0
- devcoach/web/templates/lessons.html +546 -0
- devcoach/web/templates/profile.html +240 -0
- devcoach/web/templates/settings.html +144 -0
- devcoach-0.1.0.dist-info/METADATA +443 -0
- devcoach-0.1.0.dist-info/RECORD +43 -0
- devcoach-0.1.0.dist-info/WHEEL +4 -0
- devcoach-0.1.0.dist-info/entry_points.txt +2 -0
- devcoach-0.1.0.dist-info/licenses/LICENSE +201 -0
- devcoach-0.1.0.dist-info/licenses/NOTICE +20 -0
devcoach/core/detect.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""File-based tech stack detection for devcoach onboarding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# (glob_pattern, topic_id, default_confidence)
|
|
9
|
+
# Confidence 6 = Intermediate (working knowledge); 7 = confident use.
|
|
10
|
+
STACK_SIGNALS: list[tuple[str, str, int]] = [
|
|
11
|
+
("package.json", "javascript", 6),
|
|
12
|
+
("tsconfig*.json", "typescript", 6),
|
|
13
|
+
("requirements*.txt", "python", 6),
|
|
14
|
+
("pyproject.toml", "python", 6),
|
|
15
|
+
("setup.py", "python", 6),
|
|
16
|
+
("go.mod", "go", 6),
|
|
17
|
+
("Cargo.toml", "rust", 6),
|
|
18
|
+
("pom.xml", "java", 6),
|
|
19
|
+
("build.gradle", "java", 6),
|
|
20
|
+
("build.gradle.kts", "java", 6),
|
|
21
|
+
("*.csproj", "csharp", 6),
|
|
22
|
+
("Gemfile", "ruby", 6),
|
|
23
|
+
("composer.json", "php", 6),
|
|
24
|
+
("Dockerfile", "docker", 7),
|
|
25
|
+
("Dockerfile.*", "docker", 7),
|
|
26
|
+
("docker-compose.yml", "docker_compose", 7),
|
|
27
|
+
("docker-compose.yaml", "docker_compose", 7),
|
|
28
|
+
("docker-compose.*.yml", "docker_compose", 7),
|
|
29
|
+
(".github/workflows/*.yml", "github_actions", 6),
|
|
30
|
+
(".github/workflows/*.yaml", "github_actions", 6),
|
|
31
|
+
(".gitlab-ci.yml", "gitlab_ci", 6),
|
|
32
|
+
("Jenkinsfile", "ci_cd", 6),
|
|
33
|
+
("*.tf", "terraform", 6),
|
|
34
|
+
("*.sql", "sql", 5),
|
|
35
|
+
(".git", "git", 7),
|
|
36
|
+
("kubernetes/*.yaml", "kubernetes", 6),
|
|
37
|
+
("kubernetes/*.yml", "kubernetes", 6),
|
|
38
|
+
("k8s/*.yaml", "kubernetes", 6),
|
|
39
|
+
("*.bicep", "azure", 6),
|
|
40
|
+
("serverless.yml", "aws", 6),
|
|
41
|
+
("serverless.yaml", "aws", 6),
|
|
42
|
+
("template.yaml", "aws", 5), # SAM
|
|
43
|
+
("angular.json", "angular", 6),
|
|
44
|
+
("svelte.config.*", "svelte", 6),
|
|
45
|
+
("nuxt.config.*", "vue", 6),
|
|
46
|
+
("vite.config.*", "javascript", 6),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
# package.json devDependencies/dependencies → framework topic
|
|
50
|
+
_JS_FRAMEWORK_MAP: dict[str, str] = {
|
|
51
|
+
"react": "react",
|
|
52
|
+
"react-dom": "react",
|
|
53
|
+
"vue": "vue",
|
|
54
|
+
"@angular/core": "angular",
|
|
55
|
+
"svelte": "svelte",
|
|
56
|
+
"next": "nextjs",
|
|
57
|
+
"nuxt": "vue",
|
|
58
|
+
"fastify": "fastify",
|
|
59
|
+
"express": "express",
|
|
60
|
+
"koa": "express",
|
|
61
|
+
"@nestjs/core": "node_js",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# requirements.txt / pyproject.toml → framework topic
|
|
65
|
+
_PYTHON_FRAMEWORK_MAP: dict[str, str] = {
|
|
66
|
+
"django": "django",
|
|
67
|
+
"flask": "flask",
|
|
68
|
+
"fastapi": "fastapi",
|
|
69
|
+
"starlette": "fastapi",
|
|
70
|
+
"tornado": "python",
|
|
71
|
+
"aiohttp": "python",
|
|
72
|
+
"sqlalchemy": "postgresql",
|
|
73
|
+
"celery": "python",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def detect_stack(folder: str) -> dict[str, int]:
|
|
78
|
+
"""Scan *folder* for technology signals.
|
|
79
|
+
|
|
80
|
+
Returns {topic_id: confidence} as suggestions for the onboarding
|
|
81
|
+
conversation. Values are defaults only — the user should confirm or
|
|
82
|
+
adjust each one.
|
|
83
|
+
"""
|
|
84
|
+
root = Path(folder)
|
|
85
|
+
result: dict[str, int] = {}
|
|
86
|
+
|
|
87
|
+
def _add(topic: str, confidence: int) -> None:
|
|
88
|
+
# Keep the highest confidence seen for a topic
|
|
89
|
+
if result.get(topic, -1) < confidence:
|
|
90
|
+
result[topic] = confidence
|
|
91
|
+
|
|
92
|
+
for pattern, topic, confidence in STACK_SIGNALS:
|
|
93
|
+
if list(root.glob(pattern)):
|
|
94
|
+
_add(topic, confidence)
|
|
95
|
+
|
|
96
|
+
# Deeper inspection: package.json → JS frameworks
|
|
97
|
+
pkg_path = root / "package.json"
|
|
98
|
+
if pkg_path.exists():
|
|
99
|
+
try:
|
|
100
|
+
pkg = json.loads(pkg_path.read_text(encoding="utf-8", errors="replace"))
|
|
101
|
+
all_deps: set[str] = set()
|
|
102
|
+
all_deps.update(pkg.get("dependencies", {}).keys())
|
|
103
|
+
all_deps.update(pkg.get("devDependencies", {}).keys())
|
|
104
|
+
for dep, framework_topic in _JS_FRAMEWORK_MAP.items():
|
|
105
|
+
if dep in all_deps:
|
|
106
|
+
_add(framework_topic, 6)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
# Deeper inspection: pyproject.toml / requirements → Python frameworks
|
|
111
|
+
for req_file in ("requirements.txt", "requirements-dev.txt", "requirements/base.txt"):
|
|
112
|
+
req_path = root / req_file
|
|
113
|
+
if req_path.exists():
|
|
114
|
+
try:
|
|
115
|
+
text = req_path.read_text(encoding="utf-8", errors="replace").lower()
|
|
116
|
+
for pkg_name, framework_topic in _PYTHON_FRAMEWORK_MAP.items():
|
|
117
|
+
if pkg_name in text:
|
|
118
|
+
_add(framework_topic, 6)
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
pyproject_path = root / "pyproject.toml"
|
|
123
|
+
if pyproject_path.exists():
|
|
124
|
+
try:
|
|
125
|
+
text = pyproject_path.read_text(encoding="utf-8", errors="replace").lower()
|
|
126
|
+
for pkg_name, framework_topic in _PYTHON_FRAMEWORK_MAP.items():
|
|
127
|
+
if pkg_name in text:
|
|
128
|
+
_add(framework_topic, 6)
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
return result
|
devcoach/core/git.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Git context auto-detection for devcoach."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _run(*args: str) -> str | None:
|
|
11
|
+
"""Run a git command and return stdout, or None on any error."""
|
|
12
|
+
try:
|
|
13
|
+
result = subprocess.run(
|
|
14
|
+
list(args),
|
|
15
|
+
capture_output=True,
|
|
16
|
+
text=True,
|
|
17
|
+
timeout=3,
|
|
18
|
+
)
|
|
19
|
+
if result.returncode == 0:
|
|
20
|
+
return result.stdout.strip() or None
|
|
21
|
+
except Exception:
|
|
22
|
+
pass
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_remote(remote: str | None) -> tuple[str | None, str | None]:
|
|
27
|
+
"""Parse a git remote URL into (repository, platform).
|
|
28
|
+
|
|
29
|
+
Handles:
|
|
30
|
+
git@github.com:org/repo.git → ("org/repo", "github")
|
|
31
|
+
https://github.com/org/repo → ("org/repo", "github")
|
|
32
|
+
https://gitlab.com/org/repo.git → ("org/repo", "gitlab")
|
|
33
|
+
https://bitbucket.org/org/repo → ("org/repo", "bitbucket")
|
|
34
|
+
anything else with a remote → (remote, "local")
|
|
35
|
+
no remote → (None, None)
|
|
36
|
+
"""
|
|
37
|
+
if not remote:
|
|
38
|
+
return None, None
|
|
39
|
+
|
|
40
|
+
_PLATFORM_MAP = {
|
|
41
|
+
"github.com": "github",
|
|
42
|
+
"gitlab.com": "gitlab",
|
|
43
|
+
"bitbucket.org": "bitbucket",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# SSH: git@host:org/repo.git
|
|
47
|
+
ssh_match = re.match(r"git@([^:]+):(.+?)(?:\.git)?$", remote)
|
|
48
|
+
if ssh_match:
|
|
49
|
+
host = ssh_match.group(1).lower()
|
|
50
|
+
path = ssh_match.group(2)
|
|
51
|
+
platform = _PLATFORM_MAP.get(host, "local")
|
|
52
|
+
return path, platform
|
|
53
|
+
|
|
54
|
+
# HTTPS: https://host/org/repo[.git]
|
|
55
|
+
https_match = re.match(r"https?://([^/]+)/(.+?)(?:\.git)?$", remote)
|
|
56
|
+
if https_match:
|
|
57
|
+
host = https_match.group(1).lower()
|
|
58
|
+
path = https_match.group(2)
|
|
59
|
+
platform = _PLATFORM_MAP.get(host, "local")
|
|
60
|
+
return path, platform
|
|
61
|
+
|
|
62
|
+
return remote, "local"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def detect_git_context() -> dict[str, str | None]:
|
|
66
|
+
"""Detect git metadata from the current working directory.
|
|
67
|
+
|
|
68
|
+
Returns a dict with keys: project, repository, branch, commit_hash,
|
|
69
|
+
folder, repository_platform. Any field that cannot be determined is None.
|
|
70
|
+
All subprocess calls have a 3-second timeout and never raise.
|
|
71
|
+
"""
|
|
72
|
+
folder = str(Path.cwd())
|
|
73
|
+
branch = _run("git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
74
|
+
commit = _run("git", "rev-parse", "HEAD")
|
|
75
|
+
remote = _run("git", "remote", "get-url", "origin")
|
|
76
|
+
|
|
77
|
+
repository, platform = _parse_remote(remote)
|
|
78
|
+
|
|
79
|
+
if repository:
|
|
80
|
+
# Use last path component as project name
|
|
81
|
+
project: str | None = repository.rstrip("/").split("/")[-1]
|
|
82
|
+
else:
|
|
83
|
+
# Fall back to cwd folder name
|
|
84
|
+
project = Path(folder).name or None
|
|
85
|
+
|
|
86
|
+
# HEAD detached check
|
|
87
|
+
if branch == "HEAD":
|
|
88
|
+
branch = None
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"project": project,
|
|
92
|
+
"repository": repository,
|
|
93
|
+
"branch": branch,
|
|
94
|
+
"commit_hash": commit,
|
|
95
|
+
"folder": folder,
|
|
96
|
+
"repository_platform": platform,
|
|
97
|
+
}
|
devcoach/core/models.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Pydantic v2 models for devcoach."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, field_serializer, field_validator
|
|
9
|
+
|
|
10
|
+
# ── Domain type aliases ────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
Level = Literal["junior", "mid", "senior"]
|
|
13
|
+
RepositoryPlatform = Literal["github", "gitlab", "bitbucket", "local"]
|
|
14
|
+
Feedback = Literal["know", "dont_know"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── Models ─────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Lesson(BaseModel):
|
|
21
|
+
"""A coaching lesson delivered to the user."""
|
|
22
|
+
|
|
23
|
+
id: str
|
|
24
|
+
timestamp: datetime
|
|
25
|
+
topic_id: str
|
|
26
|
+
categories: list[str]
|
|
27
|
+
title: str
|
|
28
|
+
level: Level
|
|
29
|
+
summary: str
|
|
30
|
+
task_context: str | None = None
|
|
31
|
+
project: str | None = None
|
|
32
|
+
repository: str | None = None
|
|
33
|
+
branch: str | None = None
|
|
34
|
+
commit_hash: str | None = None
|
|
35
|
+
folder: str | None = None
|
|
36
|
+
repository_platform: RepositoryPlatform | None = None
|
|
37
|
+
starred: bool = False
|
|
38
|
+
feedback: Feedback | None = None
|
|
39
|
+
|
|
40
|
+
@field_validator("timestamp", mode="before")
|
|
41
|
+
@classmethod
|
|
42
|
+
def parse_and_normalize_timestamp(cls, v: str | datetime) -> datetime:
|
|
43
|
+
"""Accept any ISO 8601 string or datetime; always return UTC-aware datetime."""
|
|
44
|
+
if isinstance(v, datetime):
|
|
45
|
+
return v if v.tzinfo else v.replace(tzinfo=UTC)
|
|
46
|
+
try:
|
|
47
|
+
dt = datetime.fromisoformat(v)
|
|
48
|
+
if dt.tzinfo is None:
|
|
49
|
+
dt = dt.replace(tzinfo=UTC)
|
|
50
|
+
return dt.astimezone(UTC)
|
|
51
|
+
except ValueError:
|
|
52
|
+
raise ValueError(f"Cannot parse timestamp {v!r} — expected ISO 8601")
|
|
53
|
+
|
|
54
|
+
@field_serializer("timestamp")
|
|
55
|
+
def serialize_timestamp(self, v: datetime) -> str:
|
|
56
|
+
"""Serialize to UTC ISO 8601 string with Z suffix for JSON output."""
|
|
57
|
+
return v.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def timestamp_iso(self) -> str:
|
|
61
|
+
"""UTC ISO 8601 string with Z suffix, e.g. '2025-01-15T20:30:00Z'."""
|
|
62
|
+
return self.timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class KnowledgeEntry(BaseModel):
|
|
66
|
+
"""A single topic in the knowledge map."""
|
|
67
|
+
|
|
68
|
+
topic: str
|
|
69
|
+
confidence: int # 0-10
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class KnowledgeGroup(BaseModel):
|
|
73
|
+
"""A named group containing a list of topic IDs."""
|
|
74
|
+
|
|
75
|
+
name: str
|
|
76
|
+
topics: list[str]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Profile(BaseModel):
|
|
80
|
+
"""The user's full knowledge map with group definitions."""
|
|
81
|
+
|
|
82
|
+
knowledge: list[KnowledgeEntry]
|
|
83
|
+
groups: list[KnowledgeGroup]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Settings(BaseModel):
|
|
87
|
+
"""Server configuration settings."""
|
|
88
|
+
|
|
89
|
+
max_per_day: int = 2
|
|
90
|
+
min_gap_minutes: int = 240 # replaces min_hours_between (4h default)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class KnowledgeUpdate(BaseModel):
|
|
94
|
+
"""Input for update_knowledge tool."""
|
|
95
|
+
|
|
96
|
+
topic: str
|
|
97
|
+
delta: int
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class RateLimitResult(BaseModel):
|
|
101
|
+
"""Result from the check_rate_limit tool."""
|
|
102
|
+
|
|
103
|
+
allowed: bool
|
|
104
|
+
reason: str | None = None
|
devcoach/core/prompts.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Lesson template builders by knowledge level for devcoach."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from devcoach.core.models import Lesson
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format_lesson_for_display(lesson: Lesson) -> str:
|
|
9
|
+
"""Format a Lesson as the markdown block appended to a coaching response."""
|
|
10
|
+
level_label = lesson.level.capitalize()
|
|
11
|
+
category_str = " · ".join(lesson.categories)
|
|
12
|
+
lines = [
|
|
13
|
+
"---",
|
|
14
|
+
f"🎓 **devcoach** · {category_str} · Level: {level_label}",
|
|
15
|
+
"",
|
|
16
|
+
f"**{lesson.title}**",
|
|
17
|
+
"",
|
|
18
|
+
lesson.summary,
|
|
19
|
+
]
|
|
20
|
+
if lesson.task_context:
|
|
21
|
+
lines += ["", f"*Context: {lesson.task_context}*"]
|
|
22
|
+
return "\n".join(lines)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_prompt_for_level(topic: str, context: str, confidence: int) -> str:
|
|
26
|
+
"""Select the appropriate prompt template based on confidence score.
|
|
27
|
+
|
|
28
|
+
0-3 → junior
|
|
29
|
+
4-6 → mid
|
|
30
|
+
7-9 → senior
|
|
31
|
+
10 → topic mastered, returns empty string
|
|
32
|
+
"""
|
|
33
|
+
if confidence <= 3:
|
|
34
|
+
return (
|
|
35
|
+
f"Explain '{topic}' for a beginner. Use a simple analogy. "
|
|
36
|
+
f"Avoid jargon. Show a minimal code example if helpful. "
|
|
37
|
+
f"Connect the explanation to: {context}"
|
|
38
|
+
)
|
|
39
|
+
elif confidence <= 6:
|
|
40
|
+
return (
|
|
41
|
+
f"Explain the 'why' behind '{topic}' for an intermediate developer. "
|
|
42
|
+
f"Mention two or three alternative approaches and their tradeoffs. "
|
|
43
|
+
f"Connect the explanation to: {context}"
|
|
44
|
+
)
|
|
45
|
+
elif confidence <= 9:
|
|
46
|
+
return (
|
|
47
|
+
f"Give a senior-level perspective on '{topic}'. "
|
|
48
|
+
f"Focus on edge cases, production tradeoffs, and historical context. "
|
|
49
|
+
f"Assume the reader already knows the basics. "
|
|
50
|
+
f"Connect the explanation to: {context}"
|
|
51
|
+
)
|
|
52
|
+
return ""
|
devcoach/mcp/__init__.py
ADDED
|
File without changes
|