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.
Files changed (43) hide show
  1. devcoach/SKILL.md +288 -0
  2. devcoach/__init__.py +3 -0
  3. devcoach/cli/__init__.py +0 -0
  4. devcoach/cli/commands.py +793 -0
  5. devcoach/core/__init__.py +0 -0
  6. devcoach/core/coach.py +141 -0
  7. devcoach/core/db.py +768 -0
  8. devcoach/core/detect.py +132 -0
  9. devcoach/core/git.py +97 -0
  10. devcoach/core/models.py +104 -0
  11. devcoach/core/prompts.py +52 -0
  12. devcoach/mcp/__init__.py +0 -0
  13. devcoach/mcp/server.py +545 -0
  14. devcoach/web/__init__.py +0 -0
  15. devcoach/web/app.py +319 -0
  16. devcoach/web/static/favicon.svg +3 -0
  17. devcoach/web/static/relative-time.js +24 -0
  18. devcoach/web/static/style.css +163 -0
  19. devcoach/web/static/vendor/alpinejs.min.js +5 -0
  20. devcoach/web/static/vendor/flatpickr-dark.min.css +795 -0
  21. devcoach/web/static/vendor/flatpickr.min.css +13 -0
  22. devcoach/web/static/vendor/flatpickr.min.js +2 -0
  23. devcoach/web/static/vendor/highlight.min.js +1232 -0
  24. devcoach/web/static/vendor/hljs-dark.min.css +1 -0
  25. devcoach/web/static/vendor/hljs-light.min.css +1 -0
  26. devcoach/web/static/vendor/htmx.min.js +1 -0
  27. devcoach/web/static/vendor/icons/bitbucket.svg +1 -0
  28. devcoach/web/static/vendor/icons/github.svg +1 -0
  29. devcoach/web/static/vendor/icons/gitlab.svg +1 -0
  30. devcoach/web/static/vendor/icons/vscode.svg +41 -0
  31. devcoach/web/static/vendor/marked.min.js +6 -0
  32. devcoach/web/static/vendor/tailwind.js +83 -0
  33. devcoach/web/templates/base.html +80 -0
  34. devcoach/web/templates/lesson_detail.html +215 -0
  35. devcoach/web/templates/lessons.html +546 -0
  36. devcoach/web/templates/profile.html +240 -0
  37. devcoach/web/templates/settings.html +144 -0
  38. devcoach-0.1.0.dist-info/METADATA +443 -0
  39. devcoach-0.1.0.dist-info/RECORD +43 -0
  40. devcoach-0.1.0.dist-info/WHEEL +4 -0
  41. devcoach-0.1.0.dist-info/entry_points.txt +2 -0
  42. devcoach-0.1.0.dist-info/licenses/LICENSE +201 -0
  43. devcoach-0.1.0.dist-info/licenses/NOTICE +20 -0
@@ -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
+ }
@@ -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
@@ -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 ""
File without changes