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,793 @@
1
+ """CLI subcommands for devcoach — rendered with rich."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from rich import box
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from devcoach.core import coach, db
15
+
16
+ console = Console()
17
+
18
+
19
+ # ── Helpers ────────────────────────────────────────────────────────────────
20
+
21
+
22
+ def _confidence_bar(confidence: int) -> str:
23
+ filled = round(confidence * 10 / 10)
24
+ return "█" * filled + "░" * (10 - filled)
25
+
26
+
27
+ # ── Subcommand handlers ────────────────────────────────────────────────────
28
+
29
+
30
+ def cmd_profile(_args: argparse.Namespace) -> None:
31
+ with db.connection() as conn:
32
+ profile = coach.get_profile(conn)
33
+
34
+ topic_group = {t: g.name for g in profile.groups for t in g.topics}
35
+
36
+ table = Table(title="Knowledge Map", box=box.ROUNDED, show_lines=False)
37
+ table.add_column("Topic", style="cyan", no_wrap=True)
38
+ table.add_column("Group", style="dim", no_wrap=True)
39
+ table.add_column("Confidence", justify="right")
40
+ table.add_column("Bar", no_wrap=True)
41
+
42
+ for entry in sorted(profile.knowledge, key=lambda e: -e.confidence):
43
+ bar = _confidence_bar(entry.confidence)
44
+ color = "green" if entry.confidence >= 7 else "yellow" if entry.confidence >= 4 else "red"
45
+ group = topic_group.get(entry.topic, "Other")
46
+ table.add_row(
47
+ entry.topic,
48
+ group,
49
+ f"[{color}]{entry.confidence}/10[/{color}]",
50
+ f"[{color}]{bar}[/{color}]",
51
+ )
52
+
53
+ console.print(table)
54
+
55
+
56
+ def cmd_lessons(args: argparse.Namespace) -> None:
57
+ starred_filter = True if getattr(args, "starred", False) else None
58
+ feedback_filter = getattr(args, "feedback", None) or None
59
+ date_from = getattr(args, "date_from", None) or None
60
+ date_to = getattr(args, "date_to", None) or None
61
+ level_filter = getattr(args, "level", None) or None
62
+ sort_col = getattr(args, "sort", "timestamp") or "timestamp"
63
+ sort_order = getattr(args, "order", "desc") or "desc"
64
+
65
+ with db.connection() as conn:
66
+ lessons = db.get_lessons(
67
+ conn,
68
+ period=args.period if args.period != "all" else None,
69
+ category=args.category or None,
70
+ level=level_filter,
71
+ project=args.project or None,
72
+ repository=args.repository or None,
73
+ branch=args.branch or None,
74
+ commit=args.commit or None,
75
+ starred=starred_filter,
76
+ feedback=feedback_filter,
77
+ date_from=date_from,
78
+ date_to=date_to,
79
+ sort=sort_col,
80
+ order=sort_order,
81
+ )
82
+
83
+ if not lessons:
84
+ console.print("[dim]No lessons found.[/dim]")
85
+ return
86
+
87
+ has_meta = any(lesson.project or lesson.branch or lesson.commit_hash for lesson in lessons)
88
+
89
+ table = Table(title="Lessons", box=box.ROUNDED, show_lines=False)
90
+ table.add_column("", no_wrap=True, width=2)
91
+ table.add_column("Date", no_wrap=True)
92
+ table.add_column("Topic", style="cyan")
93
+ table.add_column("Title")
94
+ table.add_column("Level", justify="center")
95
+ table.add_column("Categories")
96
+ if has_meta:
97
+ table.add_column("Project", style="dim")
98
+ table.add_column("Branch", style="magenta")
99
+ table.add_column("Commit", style="cyan", no_wrap=True)
100
+
101
+ for lesson in lessons:
102
+ level_color = {"junior": "green", "mid": "yellow", "senior": "red"}.get(
103
+ lesson.level, "white"
104
+ )
105
+ feedback_icon = (
106
+ " [green]✓[/green]"
107
+ if lesson.feedback == "know"
108
+ else (" [red]✗[/red]" if lesson.feedback == "dont_know" else "")
109
+ )
110
+ row = [
111
+ "[yellow]★[/yellow]" if lesson.starred else "[dim]·[/dim]",
112
+ lesson.timestamp_iso[:10],
113
+ lesson.topic_id,
114
+ lesson.title + feedback_icon,
115
+ f"[{level_color}]{lesson.level}[/{level_color}]",
116
+ ", ".join(lesson.categories),
117
+ ]
118
+ if has_meta:
119
+ row += [
120
+ lesson.project or "",
121
+ lesson.branch or "",
122
+ lesson.commit_hash[:7] if lesson.commit_hash else "",
123
+ ]
124
+ table.add_row(*row)
125
+
126
+ console.print(table)
127
+
128
+
129
+ def cmd_star(args: argparse.Namespace) -> None:
130
+ with db.connection() as conn:
131
+ lesson = db.get_lesson_by_id(conn, args.id)
132
+ if lesson is None:
133
+ console.print(f"[red]Lesson '{args.id}' not found.[/red]")
134
+ sys.exit(1)
135
+ new_state = db.toggle_star(conn, args.id)
136
+ state_label = "[yellow]★ starred[/yellow]" if new_state else "[dim]☆ unstarred[/dim]"
137
+ console.print(f"Lesson [cyan]{args.id}[/cyan] → {state_label}")
138
+
139
+
140
+ def cmd_feedback(args: argparse.Namespace) -> None:
141
+ valid = {"know", "dont_know", "clear"}
142
+ if args.feedback not in valid:
143
+ console.print(
144
+ f"[red]Invalid feedback '{args.feedback}'. Use: know | dont_know | clear[/red]"
145
+ )
146
+ sys.exit(1)
147
+
148
+ feedback_value = None if args.feedback == "clear" else args.feedback
149
+ with db.connection() as conn:
150
+ topic_id = coach.record_feedback(conn, args.id, feedback_value)
151
+ if topic_id is None:
152
+ console.print(f"[red]Lesson '{args.id}' not found.[/red]")
153
+ sys.exit(1)
154
+ row = conn.execute(
155
+ "SELECT confidence FROM knowledge WHERE topic = ?", (topic_id,)
156
+ ).fetchone()
157
+ new_conf = row[0] if row else 5
158
+
159
+ if feedback_value in ("know", "dont_know"):
160
+ old_conf = new_conf + (-1 if feedback_value == "know" else 1)
161
+ conf_label = f"[cyan]{topic_id}[/cyan] confidence: {old_conf} → [bold]{new_conf}[/bold]"
162
+ else:
163
+ conf_label = "feedback cleared"
164
+
165
+ icon = {
166
+ "know": "[green]✓ I know this[/green]",
167
+ "dont_know": "[red]✗ I don't know this[/red]",
168
+ }.get(feedback_value or "", "[dim]cleared[/dim]")
169
+ console.print(f"Lesson [cyan]{args.id}[/cyan] → {icon} ({conf_label})")
170
+
171
+
172
+ def cmd_lesson(args: argparse.Namespace) -> None:
173
+ with db.connection() as conn:
174
+ lesson = db.get_lesson_by_id(conn, args.id)
175
+
176
+ if lesson is None:
177
+ console.print(f"[red]Lesson '{args.id}' not found.[/red]")
178
+ sys.exit(1)
179
+
180
+ level_color = {"junior": "green", "mid": "yellow", "senior": "red"}.get(lesson.level, "white")
181
+
182
+ console.rule(f"[bold]{lesson.title}[/bold]")
183
+ console.print(f"[dim]ID:[/dim] {lesson.id}")
184
+ console.print(f"[dim]Date:[/dim] {lesson.timestamp_iso[:19].replace('T', ' ')}")
185
+ console.print(f"[dim]Topic:[/dim] {lesson.topic_id}")
186
+ console.print(f"[dim]Categories:[/dim] {', '.join(lesson.categories)}")
187
+ console.print(f"[dim]Level:[/dim] [{level_color}]{lesson.level}[/{level_color}]")
188
+ star_label = "[yellow]★ starred[/yellow]" if lesson.starred else "[dim]☆ not starred[/dim]"
189
+ feedback_label = (
190
+ "[green]✓ I know this[/green]"
191
+ if lesson.feedback == "know"
192
+ else "[red]✗ I don't know this[/red]"
193
+ if lesson.feedback == "dont_know"
194
+ else "[dim]no feedback[/dim]"
195
+ )
196
+ console.print(f"[dim]Star:[/dim] {star_label} [dim]Feedback:[/dim] {feedback_label}")
197
+ if lesson.task_context:
198
+ console.print(f"[dim]Context:[/dim] {lesson.task_context}")
199
+ if lesson.project or lesson.repository or lesson.branch or lesson.commit_hash or lesson.folder:
200
+ meta_parts = []
201
+ if lesson.project:
202
+ meta_parts.append(f"project={lesson.project}")
203
+ if lesson.repository:
204
+ meta_parts.append(f"repo={lesson.repository}")
205
+ if lesson.branch:
206
+ meta_parts.append(f"branch=[magenta]{lesson.branch}[/magenta]")
207
+ if lesson.commit_hash:
208
+ meta_parts.append(f"commit=[cyan]{lesson.commit_hash[:7]}[/cyan]")
209
+ if lesson.folder:
210
+ meta_parts.append(f"folder={lesson.folder}")
211
+ console.print(f"[dim]Git:[/dim] {' · '.join(meta_parts)}")
212
+ console.rule()
213
+ console.print(lesson.summary)
214
+
215
+
216
+ def cmd_settings(_args: argparse.Namespace) -> None:
217
+ with db.connection() as conn:
218
+ settings = db.get_settings(conn)
219
+
220
+ gap_h, gap_m = divmod(settings.min_gap_minutes, 60)
221
+ gap_label = f"{gap_h}h {gap_m}m" if gap_h else f"{gap_m}m"
222
+
223
+ table = Table(title="Settings", box=box.ROUNDED)
224
+ table.add_column("Key", style="cyan")
225
+ table.add_column("Value", justify="right")
226
+ table.add_row("max_per_day", str(settings.max_per_day))
227
+ table.add_row("min_gap_minutes", f"{settings.min_gap_minutes} ({gap_label})")
228
+ console.print(table)
229
+
230
+
231
+ def cmd_stats(_args: argparse.Namespace) -> None:
232
+ with db.connection() as conn:
233
+ stats = coach.get_stats(conn)
234
+ rate_limit = coach.check_rate_limit(conn)
235
+ settings = db.get_settings(conn)
236
+
237
+ table = Table(title="Coaching Stats", box=box.ROUNDED, show_header=False)
238
+ table.add_column("Metric", style="dim")
239
+ table.add_column("Value", justify="right")
240
+ table.add_row("Total lessons", str(stats.get("total_lessons", 0)))
241
+ table.add_row(
242
+ "Lessons today (24h)", f"{stats.get('lessons_today', 0)} / {settings.max_per_day}"
243
+ )
244
+ table.add_row("Lessons this week", str(stats.get("lessons_this_week", 0)))
245
+ rl_label = (
246
+ "[green]Available now[/green]"
247
+ if rate_limit.allowed
248
+ else f"[yellow]{rate_limit.reason}[/yellow]"
249
+ )
250
+ table.add_row("Next lesson", rl_label)
251
+ console.print(table)
252
+
253
+ weakest = stats.get("weakest_topics", [])
254
+ strongest = stats.get("strongest_topics", [])
255
+
256
+ if weakest or strongest:
257
+ side = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))
258
+ side.add_column("Weakest topics", style="red", no_wrap=True)
259
+ side.add_column(" ")
260
+ side.add_column("Strongest topics", style="green", no_wrap=True)
261
+ for i in range(max(len(weakest), len(strongest))):
262
+ w = weakest[i] if i < len(weakest) else None
263
+ s = strongest[i] if i < len(strongest) else None
264
+ w_cell = f"{w['topic']} [dim]({w['confidence']})[/dim]" if w else ""
265
+ s_cell = f"{s['topic']} [dim]({s['confidence']})[/dim]" if s else ""
266
+ side.add_row(w_cell, "", s_cell)
267
+ console.print(side)
268
+
269
+
270
+ def cmd_set(args: argparse.Namespace) -> None:
271
+ valid_keys = {"max_per_day", "min_gap_minutes"}
272
+ if args.key not in valid_keys:
273
+ console.print(
274
+ f"[red]Unknown key '{args.key}'. Valid keys: {', '.join(sorted(valid_keys))}[/red]"
275
+ )
276
+ sys.exit(1)
277
+
278
+ with db.connection() as conn:
279
+ db.set_setting(conn, args.key, args.value)
280
+ console.print(f"[green]Set {args.key} = {args.value}[/green]")
281
+
282
+
283
+ def cmd_knowledge_add(args: argparse.Namespace) -> None:
284
+ topic = args.topic.strip()
285
+ if not topic:
286
+ console.print("[red]Topic name must not be empty.[/red]")
287
+ sys.exit(1)
288
+ with db.connection() as conn:
289
+ db.upsert_knowledge(conn, topic, args.confidence)
290
+ if args.group and args.group != "Other":
291
+ db.assign_topic_to_group(conn, topic, args.group)
292
+ group_label = f" → [cyan]{args.group}[/cyan]" if args.group and args.group != "Other" else ""
293
+ console.print(
294
+ f"[green]Added[/green] [bold]{topic}[/bold] (confidence [cyan]{args.confidence}[/cyan]){group_label}"
295
+ )
296
+
297
+
298
+ def cmd_knowledge_remove(args: argparse.Namespace) -> None:
299
+ with db.connection() as conn:
300
+ removed = db.delete_knowledge(conn, args.topic)
301
+ if removed:
302
+ console.print(f"[green]Removed[/green] [bold]{args.topic}[/bold] from knowledge map")
303
+ else:
304
+ console.print(f"[yellow]Topic '{args.topic}' not found.[/yellow]")
305
+
306
+
307
+ def cmd_group_add(args: argparse.Namespace) -> None:
308
+ group_name = args.name.strip()
309
+ if not group_name or group_name == "Other":
310
+ console.print("[red]Invalid group name.[/red]")
311
+ sys.exit(1)
312
+ with db.connection() as conn:
313
+ db.add_group(conn, group_name)
314
+ console.print(
315
+ f"[green]Group '[cyan]{group_name}[/cyan]' ready.[/green] Assign topics with: devcoach group-assign <topic> \"{group_name}\""
316
+ )
317
+
318
+
319
+ def cmd_group_remove(args: argparse.Namespace) -> None:
320
+ with db.connection() as conn:
321
+ count = db.delete_group(conn, args.name)
322
+ if count:
323
+ console.print(
324
+ f"[green]Removed group '[cyan]{args.name}[/cyan]'[/green] ({count} topic assignment(s) cleared)"
325
+ )
326
+ else:
327
+ console.print(f"[yellow]Group '{args.name}' not found or already empty.[/yellow]")
328
+
329
+
330
+ def cmd_group_assign(args: argparse.Namespace) -> None:
331
+ with db.connection() as conn:
332
+ row = conn.execute("SELECT topic FROM knowledge WHERE topic = ?", (args.topic,)).fetchone()
333
+ if row is None:
334
+ console.print(f"[red]Topic '{args.topic}' not in knowledge map. Add it first.[/red]")
335
+ sys.exit(1)
336
+ if args.group == "Other":
337
+ db.unassign_topic_from_group(conn, args.topic)
338
+ console.print(f"[green]Moved[/green] [bold]{args.topic}[/bold] → Other (ungrouped)")
339
+ else:
340
+ db.assign_topic_to_group(conn, args.topic, args.group)
341
+ console.print(
342
+ f"[green]Moved[/green] [bold]{args.topic}[/bold] → [cyan]{args.group}[/cyan]"
343
+ )
344
+
345
+
346
+ def cmd_backup(args: argparse.Namespace) -> None:
347
+ """Export settings + knowledge map + lessons as a zip file."""
348
+ with db.connection() as conn:
349
+ lessons_count = len(db.export_lessons(conn))
350
+ knowledge_count = conn.execute("SELECT COUNT(*) FROM knowledge").fetchone()[0]
351
+ data = db.create_backup_zip(conn)
352
+
353
+ out_path = Path(args.output)
354
+ out_path.write_bytes(data)
355
+ console.print(
356
+ f"[green]Backup saved:[/green] {out_path} "
357
+ f"([cyan]{lessons_count}[/cyan] lessons, "
358
+ f"[cyan]{knowledge_count}[/cyan] topics)"
359
+ )
360
+
361
+
362
+ def cmd_restore(args: argparse.Namespace) -> None:
363
+ """Restore settings + knowledge map + lessons from a backup zip file."""
364
+ in_path = Path(args.input)
365
+ if not in_path.exists():
366
+ console.print(f"[red]File not found: {in_path}[/red]")
367
+ sys.exit(1)
368
+
369
+ with db.connection() as conn:
370
+ result = db.restore_backup_zip(conn, in_path.read_bytes())
371
+
372
+ if result["settings"]:
373
+ console.print("[green]✓[/green] Settings restored")
374
+ if result["topics"]:
375
+ console.print(
376
+ f"[green]✓[/green] Knowledge map restored ([cyan]{result['topics']}[/cyan] topics)"
377
+ )
378
+ parts = [f"[cyan]{result['lessons']}[/cyan] imported"]
379
+ if result["skipped"]:
380
+ parts.append(f"[yellow]{result['skipped']}[/yellow] duplicates skipped")
381
+ if result["invalid"]:
382
+ parts.append(f"[red]{result['invalid']}[/red] rejected (invalid)")
383
+ console.print(f"[green]✓[/green] Lessons: {', '.join(parts)}")
384
+
385
+
386
+ _CLAUDE_CODE_CONFIG = Path.home() / ".claude.json"
387
+ _CLAUDE_DESKTOP_CONFIG = (
388
+ Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
389
+ )
390
+ _CLAUDE_CODE_ENTRY: dict = {
391
+ "type": "stdio",
392
+ "command": "uvx",
393
+ "args": ["devcoach"],
394
+ "env": {},
395
+ }
396
+ _CLAUDE_DESKTOP_ENTRY: dict = {
397
+ "command": "uvx",
398
+ "args": ["devcoach"],
399
+ }
400
+
401
+
402
+ def _install_to(path: Path, entry: dict, force: bool) -> str:
403
+ data: dict = json.loads(path.read_text()) if path.exists() else {}
404
+ servers: dict = data.setdefault("mcpServers", {})
405
+ if "devcoach" in servers and not force:
406
+ return f"[yellow]Already registered[/yellow] in {path} (use --force to overwrite)"
407
+ servers["devcoach"] = entry
408
+ path.parent.mkdir(parents=True, exist_ok=True)
409
+ path.write_text(json.dumps(data, indent=2) + "\n")
410
+ return f"[green]✓[/green] Installed into {path}"
411
+
412
+
413
+ def cmd_install(args: argparse.Namespace) -> None:
414
+ do_code = args.claude_code or not args.claude_desktop
415
+ do_desktop = args.claude_desktop or not args.claude_code
416
+
417
+ if do_code:
418
+ console.print(_install_to(_CLAUDE_CODE_CONFIG, _CLAUDE_CODE_ENTRY, args.force))
419
+ if do_desktop:
420
+ console.print(_install_to(_CLAUDE_DESKTOP_CONFIG, _CLAUDE_DESKTOP_ENTRY, args.force))
421
+
422
+ if do_code or do_desktop:
423
+ console.print(
424
+ "\n[dim]Restart Claude Code / Claude Desktop to pick up the new server.[/dim]"
425
+ )
426
+
427
+
428
+ def cmd_setup(_args: argparse.Namespace) -> None:
429
+ """Interactive wizard: import backup OR auto/manual stack setup + group assignment."""
430
+ import os
431
+
432
+ from devcoach.core.detect import detect_stack
433
+ from devcoach.core.git import detect_git_context
434
+
435
+ def _prompt(msg: str, default: str = "") -> str:
436
+ suffix = f" [{default}]" if default else ""
437
+ try:
438
+ val = input(f"{msg}{suffix}: ").strip()
439
+ except (EOFError, KeyboardInterrupt):
440
+ console.print("\n[dim]Setup cancelled.[/dim]")
441
+ sys.exit(0)
442
+ return val if val else default
443
+
444
+ def _prompt_int(msg: str, default: int, lo: int, hi: int) -> int:
445
+ while True:
446
+ raw = _prompt(msg, str(default))
447
+ try:
448
+ v = int(raw)
449
+ if lo <= v <= hi:
450
+ return v
451
+ console.print(f"[red]Must be {lo}–{hi}.[/red]")
452
+ except ValueError:
453
+ console.print("[red]Please enter a number.[/red]")
454
+
455
+ console.rule("[bold cyan]devcoach setup[/bold cyan]")
456
+
457
+ # ── Step 1: import? ───────────────────────────────────────────────────
458
+ console.print("\n[bold]Step 1[/bold] — Restore from backup")
459
+ backup_path = _prompt("Path to existing backup zip (Enter to skip)", "")
460
+ if backup_path:
461
+ p = Path(backup_path)
462
+ if not p.exists():
463
+ console.print(f"[red]File not found: {p}[/red]")
464
+ sys.exit(1)
465
+ with db.connection() as conn:
466
+ result = db.restore_backup_zip(conn, p.read_bytes())
467
+ db.set_setting(conn, "onboarding_completed", "1")
468
+ console.print(
469
+ f"[green]✓[/green] Restored: {result['topics']} topics, {result['lessons']} lessons"
470
+ )
471
+ console.print("[green]Setup complete![/green]")
472
+ return
473
+
474
+ # ── Step 2: auto or manual ────────────────────────────────────────────
475
+ console.print("\n[bold]Step 2[/bold] — Build your knowledge profile")
476
+ mode = _prompt(
477
+ "Mode: [a]utomatic (detect from files) / [m]anual (type your stack)", "a"
478
+ ).lower()
479
+
480
+ topics: dict[str, int] = {}
481
+
482
+ if mode.startswith("a"):
483
+ git_ctx = detect_git_context()
484
+ cwd = git_ctx.get("folder") or os.getcwd()
485
+ detected = detect_stack(cwd)
486
+
487
+ if detected:
488
+ console.print(f"\n[dim]Detected from [cyan]{cwd}[/cyan]:[/dim]")
489
+ table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))
490
+ table.add_column("Topic", style="cyan", no_wrap=True)
491
+ table.add_column("Confidence", justify="right")
492
+ console.print(table)
493
+
494
+ for topic, default_conf in sorted(detected.items()):
495
+ raw = _prompt(
496
+ f" [cyan]{topic}[/cyan] (Enter=keep, 0-10=override, s=skip)",
497
+ str(default_conf),
498
+ )
499
+ if raw.lower() == "s":
500
+ continue
501
+ try:
502
+ topics[topic] = max(0, min(10, int(raw)))
503
+ except ValueError:
504
+ topics[topic] = default_conf
505
+ else:
506
+ console.print("[dim]No technology files detected in current directory.[/dim]")
507
+
508
+ console.print("\n[dim]Add any additional topics:[/dim]")
509
+ extra = _prompt("Comma-separated topic names (or Enter to skip)", "")
510
+ if extra:
511
+ for t in [x.strip() for x in extra.split(",") if x.strip()]:
512
+ conf = _prompt_int(f" Confidence for [cyan]{t}[/cyan]", 5, 0, 10)
513
+ topics[t] = conf
514
+
515
+ else:
516
+ # Manual
517
+ console.print(
518
+ "\n[dim]Enter topics one by one. Format: topic_id confidence "
519
+ "(e.g. [cyan]python 7[/cyan]). Blank line when done.[/dim]"
520
+ )
521
+ while True:
522
+ entry = _prompt("Topic (Enter when done)", "").strip()
523
+ if not entry:
524
+ break
525
+ parts = entry.split()
526
+ t = parts[0]
527
+ try:
528
+ c = max(0, min(10, int(parts[1]))) if len(parts) > 1 else 5
529
+ except ValueError:
530
+ c = 5
531
+ topics[t] = c
532
+ console.print(f" [green]+[/green] [cyan]{t}[/cyan] → {c}")
533
+
534
+ if not topics:
535
+ console.print("[yellow]No topics selected — profile will be empty.[/yellow]")
536
+
537
+ # ── Step 3: group assignment ──────────────────────────────────────────
538
+ groups: dict[str, list[str]] = {}
539
+ if topics:
540
+ console.print("\n[bold]Step 3[/bold] — Organise into groups")
541
+ do_groups = _prompt("Would you like to organise topics into groups? [y/N]", "n").lower()
542
+ if do_groups.startswith("y"):
543
+ existing_groups: list[str] = []
544
+ for t in sorted(topics):
545
+ suggestion = ", ".join(existing_groups) if existing_groups else "(none yet)"
546
+ g = _prompt(
547
+ f" Group for [cyan]{t}[/cyan] existing: [dim]{suggestion}[/dim] (Enter=Other)",
548
+ "",
549
+ )
550
+ if g and g != "Other":
551
+ groups.setdefault(g, []).append(t)
552
+ if g not in existing_groups:
553
+ existing_groups.append(g)
554
+
555
+ # ── Step 4: settings ─────────────────────────────────────────────────
556
+ console.print("\n[bold]Step 4[/bold] — Rate-limit settings")
557
+ max_per_day = _prompt_int("Max lessons per day", 2, 1, 20)
558
+ min_gap = _prompt_int("Min gap between lessons (minutes)", 240, 0, 1440)
559
+
560
+ # ── Finish ────────────────────────────────────────────────────────────
561
+ with db.connection() as conn:
562
+ conn.execute("DELETE FROM knowledge")
563
+ conn.execute("DELETE FROM knowledge_groups")
564
+ conn.execute("DELETE FROM knowledge_group_names")
565
+ conn.commit()
566
+ for topic, confidence in topics.items():
567
+ db.upsert_knowledge(conn, topic, confidence)
568
+ for group_name, group_topics in groups.items():
569
+ for t in group_topics:
570
+ db.assign_topic_to_group(conn, t, group_name)
571
+ db.set_setting(conn, "max_per_day", str(max_per_day))
572
+ db.set_setting(conn, "min_gap_minutes", str(min_gap))
573
+ db.set_setting(conn, "onboarding_completed", "1")
574
+ profile = coach.get_profile(conn)
575
+
576
+ topic_group = {t: g.name for g in profile.groups for t in g.topics}
577
+ final_table = Table(title="Knowledge Profile", box=box.ROUNDED, show_lines=False)
578
+ final_table.add_column("Topic", style="cyan", no_wrap=True)
579
+ final_table.add_column("Group", style="dim")
580
+ final_table.add_column("Confidence", justify="right")
581
+ final_table.add_column("Bar", no_wrap=True)
582
+ for entry in sorted(profile.knowledge, key=lambda e: -e.confidence):
583
+ bar = _confidence_bar(entry.confidence)
584
+ color = "green" if entry.confidence >= 7 else "yellow" if entry.confidence >= 4 else "red"
585
+ group_name = topic_group.get(entry.topic, "Other")
586
+ final_table.add_row(
587
+ entry.topic,
588
+ group_name,
589
+ f"[{color}]{entry.confidence}/10[/{color}]",
590
+ f"[{color}]{bar}[/{color}]",
591
+ )
592
+ console.print(final_table)
593
+ console.print(f"\n[green]Setup complete![/green] {len(topics)} topics saved.")
594
+
595
+
596
+ def cmd_ui(args: argparse.Namespace) -> None:
597
+ import uvicorn
598
+
599
+ from devcoach.web.app import app
600
+
601
+ port = args.port
602
+ console.print(
603
+ f"[bold green]devcoach UI[/bold green] running at [link]http://localhost:{port}[/link]"
604
+ )
605
+ uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
606
+
607
+
608
+ # ── Parser ─────────────────────────────────────────────────────────────────
609
+
610
+
611
+ def _build_parser() -> argparse.ArgumentParser:
612
+ parser = argparse.ArgumentParser(
613
+ prog="devcoach",
614
+ description="devcoach — progressive technical coaching",
615
+ )
616
+ sub = parser.add_subparsers(dest="command")
617
+
618
+ sub.add_parser("profile", help="Show the knowledge map")
619
+
620
+ p_lessons = sub.add_parser("lessons", help="List past lessons")
621
+ p_lessons.add_argument(
622
+ "--period",
623
+ choices=["today", "week", "month", "year", "all"],
624
+ default="all",
625
+ help="Filter by time period",
626
+ )
627
+ p_lessons.add_argument("--category", default=None, help="Filter by category tag")
628
+ p_lessons.add_argument("--project", default=None, help="Filter by project name (fuzzy)")
629
+ p_lessons.add_argument("--repository", default=None, help="Filter by repository (fuzzy)")
630
+ p_lessons.add_argument("--branch", default=None, help="Filter by branch name (fuzzy)")
631
+ p_lessons.add_argument("--commit", default=None, help="Filter by commit hash prefix (fuzzy)")
632
+ p_lessons.add_argument(
633
+ "--starred", action="store_true", default=False, help="Show only starred lessons"
634
+ )
635
+ p_lessons.add_argument(
636
+ "--feedback",
637
+ choices=["know", "dont_know", "none"],
638
+ default=None,
639
+ help="Filter by feedback: know, dont_know, none (no response)",
640
+ )
641
+ p_lessons.add_argument(
642
+ "--level",
643
+ choices=["junior", "mid", "senior"],
644
+ default=None,
645
+ help="Filter by difficulty level",
646
+ )
647
+ p_lessons.add_argument(
648
+ "--date-from",
649
+ dest="date_from",
650
+ default=None,
651
+ metavar="YYYY-MM-DD[THH:MM]",
652
+ help="Show lessons on or after this date/time (e.g. 2026-04-25 or 2026-04-25T14:30)",
653
+ )
654
+ p_lessons.add_argument(
655
+ "--date-to",
656
+ dest="date_to",
657
+ default=None,
658
+ metavar="YYYY-MM-DD[THH:MM]",
659
+ help="Show lessons on or before this date/time; defaults to end-of-day if no time given",
660
+ )
661
+ p_lessons.add_argument(
662
+ "--sort",
663
+ default="timestamp",
664
+ choices=["timestamp", "level", "topic_id", "title", "feedback"],
665
+ help="Sort column (default: timestamp)",
666
+ )
667
+ p_lessons.add_argument(
668
+ "--order", default="desc", choices=["asc", "desc"], help="Sort order (default: desc)"
669
+ )
670
+
671
+ p_lesson = sub.add_parser("lesson", help="Show a single lesson in detail")
672
+ p_lesson.add_argument("id", help="Lesson ID")
673
+
674
+ p_star = sub.add_parser("star", help="Toggle starred flag on a lesson")
675
+ p_star.add_argument("id", help="Lesson ID")
676
+
677
+ p_feedback = sub.add_parser("feedback", help="Record know/dont_know feedback for a lesson")
678
+ p_feedback.add_argument("id", help="Lesson ID")
679
+ p_feedback.add_argument(
680
+ "feedback", choices=["know", "dont_know", "clear"], help="Feedback value"
681
+ )
682
+
683
+ sub.add_parser("settings", help="Show current settings")
684
+ sub.add_parser("stats", help="Show coaching statistics and rate-limit status")
685
+
686
+ p_set = sub.add_parser("set", help="Update a setting")
687
+ p_set.add_argument("key", help="Setting key (max_per_day | min_gap_minutes)")
688
+ p_set.add_argument("value", help="New value")
689
+
690
+ p_backup = sub.add_parser(
691
+ "backup", help="Export a full backup (settings + knowledge + lessons) as zip"
692
+ )
693
+ p_backup.add_argument(
694
+ "output",
695
+ nargs="?",
696
+ default="devcoach-backup.zip",
697
+ help="Output zip file path (default: devcoach-backup.zip)",
698
+ )
699
+
700
+ p_restore = sub.add_parser("restore", help="Restore from a backup zip file")
701
+ p_restore.add_argument("input", help="Path to backup zip file")
702
+
703
+ p_kadd = sub.add_parser("knowledge-add", help="Add or update a topic in the knowledge map")
704
+ p_kadd.add_argument("topic", help="Topic ID (e.g. rust_lifetimes)")
705
+ p_kadd.add_argument(
706
+ "--confidence",
707
+ type=int,
708
+ default=5,
709
+ metavar="N",
710
+ help="Initial confidence 0-10 (default: 5)",
711
+ )
712
+ p_kadd.add_argument(
713
+ "--group", default=None, metavar="GROUP", help="Assign to a named group (optional)"
714
+ )
715
+
716
+ p_kremove = sub.add_parser("knowledge-remove", help="Remove a topic from the knowledge map")
717
+ p_kremove.add_argument("topic", help="Topic ID to remove")
718
+
719
+ p_gadd = sub.add_parser("group-add", help="Register a new knowledge group")
720
+ p_gadd.add_argument("name", help="Group name (e.g. 'Machine Learning')")
721
+
722
+ p_gremove = sub.add_parser(
723
+ "group-remove", help="Delete a knowledge group (topics move to Other)"
724
+ )
725
+ p_gremove.add_argument("name", help="Group name to delete")
726
+
727
+ p_gassign = sub.add_parser("group-assign", help="Move a topic to a group")
728
+ p_gassign.add_argument("topic", help="Topic ID")
729
+ p_gassign.add_argument("group", help="Group name (use 'Other' to ungroup)")
730
+
731
+ p_install = sub.add_parser(
732
+ "install",
733
+ help="Register devcoach MCP server in Claude Code and/or Claude Desktop config",
734
+ )
735
+ p_install.add_argument(
736
+ "--claude-code",
737
+ dest="claude_code",
738
+ action="store_true",
739
+ help="Install into Claude Code only (~/.claude.json)",
740
+ )
741
+ p_install.add_argument(
742
+ "--claude-desktop",
743
+ dest="claude_desktop",
744
+ action="store_true",
745
+ help="Install into Claude Desktop only",
746
+ )
747
+ p_install.add_argument("--force", action="store_true", help="Overwrite existing devcoach entry")
748
+
749
+ p_ui = sub.add_parser("ui", help="Launch the web dashboard")
750
+ p_ui.add_argument("--port", type=int, default=7860, help="Port (default: 7860)")
751
+
752
+ sub.add_parser(
753
+ "setup",
754
+ help="Interactive first-run wizard: import backup or build knowledge profile",
755
+ )
756
+
757
+ return parser
758
+
759
+
760
+ # ── Public entry point ─────────────────────────────────────────────────────
761
+
762
+
763
+ def run_cli() -> None:
764
+ """Parse CLI arguments and dispatch to the appropriate subcommand."""
765
+ parser = _build_parser()
766
+ args = parser.parse_args()
767
+
768
+ dispatch = {
769
+ "profile": cmd_profile,
770
+ "lessons": cmd_lessons,
771
+ "lesson": cmd_lesson,
772
+ "star": cmd_star,
773
+ "feedback": cmd_feedback,
774
+ "settings": cmd_settings,
775
+ "stats": cmd_stats,
776
+ "set": cmd_set,
777
+ "backup": cmd_backup,
778
+ "restore": cmd_restore,
779
+ "knowledge-add": cmd_knowledge_add,
780
+ "knowledge-remove": cmd_knowledge_remove,
781
+ "group-add": cmd_group_add,
782
+ "group-remove": cmd_group_remove,
783
+ "group-assign": cmd_group_assign,
784
+ "install": cmd_install,
785
+ "ui": cmd_ui,
786
+ "setup": cmd_setup,
787
+ }
788
+
789
+ if args.command is None:
790
+ parser.print_help()
791
+ sys.exit(0)
792
+
793
+ dispatch[args.command](args)