doit-toolkit-cli 0.1.10__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.

Potentially problematic release.


This version of doit-toolkit-cli might be problematic. Click here for more details.

Files changed (135) hide show
  1. doit_cli/__init__.py +1356 -0
  2. doit_cli/cli/__init__.py +26 -0
  3. doit_cli/cli/analytics_command.py +616 -0
  4. doit_cli/cli/context_command.py +213 -0
  5. doit_cli/cli/diagram_command.py +304 -0
  6. doit_cli/cli/fixit_command.py +641 -0
  7. doit_cli/cli/hooks_command.py +211 -0
  8. doit_cli/cli/init_command.py +613 -0
  9. doit_cli/cli/memory_command.py +293 -0
  10. doit_cli/cli/roadmapit_command.py +10 -0
  11. doit_cli/cli/status_command.py +117 -0
  12. doit_cli/cli/sync_prompts_command.py +248 -0
  13. doit_cli/cli/validate_command.py +196 -0
  14. doit_cli/cli/verify_command.py +204 -0
  15. doit_cli/cli/workflow_mixin.py +224 -0
  16. doit_cli/cli/xref_command.py +555 -0
  17. doit_cli/formatters/__init__.py +8 -0
  18. doit_cli/formatters/base.py +38 -0
  19. doit_cli/formatters/json_formatter.py +126 -0
  20. doit_cli/formatters/markdown_formatter.py +97 -0
  21. doit_cli/formatters/rich_formatter.py +257 -0
  22. doit_cli/main.py +51 -0
  23. doit_cli/models/__init__.py +139 -0
  24. doit_cli/models/agent.py +74 -0
  25. doit_cli/models/analytics_models.py +384 -0
  26. doit_cli/models/context_config.py +464 -0
  27. doit_cli/models/crossref_models.py +182 -0
  28. doit_cli/models/diagram_models.py +363 -0
  29. doit_cli/models/fixit_models.py +355 -0
  30. doit_cli/models/hook_config.py +125 -0
  31. doit_cli/models/project.py +91 -0
  32. doit_cli/models/results.py +121 -0
  33. doit_cli/models/search_models.py +228 -0
  34. doit_cli/models/status_models.py +195 -0
  35. doit_cli/models/sync_models.py +146 -0
  36. doit_cli/models/template.py +77 -0
  37. doit_cli/models/validation_models.py +175 -0
  38. doit_cli/models/workflow_models.py +319 -0
  39. doit_cli/prompts/__init__.py +5 -0
  40. doit_cli/prompts/fixit_prompts.py +344 -0
  41. doit_cli/prompts/interactive.py +390 -0
  42. doit_cli/rules/__init__.py +5 -0
  43. doit_cli/rules/builtin_rules.py +160 -0
  44. doit_cli/services/__init__.py +79 -0
  45. doit_cli/services/agent_detector.py +168 -0
  46. doit_cli/services/analytics_service.py +218 -0
  47. doit_cli/services/architecture_generator.py +290 -0
  48. doit_cli/services/backup_service.py +204 -0
  49. doit_cli/services/config_loader.py +113 -0
  50. doit_cli/services/context_loader.py +1123 -0
  51. doit_cli/services/coverage_calculator.py +142 -0
  52. doit_cli/services/crossref_service.py +237 -0
  53. doit_cli/services/cycle_time_calculator.py +134 -0
  54. doit_cli/services/date_inferrer.py +349 -0
  55. doit_cli/services/diagram_service.py +337 -0
  56. doit_cli/services/drift_detector.py +109 -0
  57. doit_cli/services/entity_parser.py +301 -0
  58. doit_cli/services/er_diagram_generator.py +197 -0
  59. doit_cli/services/fixit_service.py +699 -0
  60. doit_cli/services/github_service.py +192 -0
  61. doit_cli/services/hook_manager.py +258 -0
  62. doit_cli/services/hook_validator.py +528 -0
  63. doit_cli/services/input_validator.py +322 -0
  64. doit_cli/services/memory_search.py +527 -0
  65. doit_cli/services/mermaid_validator.py +334 -0
  66. doit_cli/services/prompt_transformer.py +91 -0
  67. doit_cli/services/prompt_writer.py +133 -0
  68. doit_cli/services/query_interpreter.py +428 -0
  69. doit_cli/services/report_exporter.py +219 -0
  70. doit_cli/services/report_generator.py +256 -0
  71. doit_cli/services/requirement_parser.py +112 -0
  72. doit_cli/services/roadmap_summarizer.py +209 -0
  73. doit_cli/services/rule_engine.py +443 -0
  74. doit_cli/services/scaffolder.py +215 -0
  75. doit_cli/services/score_calculator.py +172 -0
  76. doit_cli/services/section_parser.py +204 -0
  77. doit_cli/services/spec_scanner.py +327 -0
  78. doit_cli/services/state_manager.py +355 -0
  79. doit_cli/services/status_reporter.py +143 -0
  80. doit_cli/services/task_parser.py +347 -0
  81. doit_cli/services/template_manager.py +710 -0
  82. doit_cli/services/template_reader.py +158 -0
  83. doit_cli/services/user_journey_generator.py +214 -0
  84. doit_cli/services/user_story_parser.py +232 -0
  85. doit_cli/services/validation_service.py +188 -0
  86. doit_cli/services/validator.py +232 -0
  87. doit_cli/services/velocity_tracker.py +173 -0
  88. doit_cli/services/workflow_engine.py +405 -0
  89. doit_cli/templates/agent-file-template.md +28 -0
  90. doit_cli/templates/checklist-template.md +39 -0
  91. doit_cli/templates/commands/doit.checkin.md +363 -0
  92. doit_cli/templates/commands/doit.constitution.md +187 -0
  93. doit_cli/templates/commands/doit.documentit.md +485 -0
  94. doit_cli/templates/commands/doit.fixit.md +181 -0
  95. doit_cli/templates/commands/doit.implementit.md +265 -0
  96. doit_cli/templates/commands/doit.planit.md +262 -0
  97. doit_cli/templates/commands/doit.reviewit.md +355 -0
  98. doit_cli/templates/commands/doit.roadmapit.md +389 -0
  99. doit_cli/templates/commands/doit.scaffoldit.md +458 -0
  100. doit_cli/templates/commands/doit.specit.md +521 -0
  101. doit_cli/templates/commands/doit.taskit.md +304 -0
  102. doit_cli/templates/commands/doit.testit.md +277 -0
  103. doit_cli/templates/config/context.yaml +134 -0
  104. doit_cli/templates/config/hooks.yaml +93 -0
  105. doit_cli/templates/config/validation-rules.yaml +64 -0
  106. doit_cli/templates/github-issue-templates/epic.yml +78 -0
  107. doit_cli/templates/github-issue-templates/feature.yml +116 -0
  108. doit_cli/templates/github-issue-templates/task.yml +129 -0
  109. doit_cli/templates/hooks/.gitkeep +0 -0
  110. doit_cli/templates/hooks/post-commit.sh +25 -0
  111. doit_cli/templates/hooks/post-merge.sh +75 -0
  112. doit_cli/templates/hooks/pre-commit.sh +17 -0
  113. doit_cli/templates/hooks/pre-push.sh +18 -0
  114. doit_cli/templates/memory/completed_roadmap.md +50 -0
  115. doit_cli/templates/memory/constitution.md +125 -0
  116. doit_cli/templates/memory/roadmap.md +61 -0
  117. doit_cli/templates/plan-template.md +146 -0
  118. doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
  119. doit_cli/templates/scripts/bash/common.sh +156 -0
  120. doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
  121. doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
  122. doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
  123. doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
  124. doit_cli/templates/scripts/powershell/common.ps1 +137 -0
  125. doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
  126. doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
  127. doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
  128. doit_cli/templates/spec-template.md +159 -0
  129. doit_cli/templates/tasks-template.md +313 -0
  130. doit_cli/templates/vscode-settings.json +14 -0
  131. doit_toolkit_cli-0.1.10.dist-info/METADATA +324 -0
  132. doit_toolkit_cli-0.1.10.dist-info/RECORD +135 -0
  133. doit_toolkit_cli-0.1.10.dist-info/WHEEL +4 -0
  134. doit_toolkit_cli-0.1.10.dist-info/entry_points.txt +2 -0
  135. doit_toolkit_cli-0.1.10.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,26 @@
1
+ """CLI commands for doit-cli."""
2
+
3
+ from .init_command import init_command, run_init, parse_agent_string
4
+ from .memory_command import memory_app
5
+ from .verify_command import verify_command
6
+ from .workflow_mixin import (
7
+ WorkflowMixin,
8
+ non_interactive_option,
9
+ workflow_command_options,
10
+ validate_required_defaults,
11
+ create_non_interactive_workflow,
12
+ )
13
+
14
+ __all__ = [
15
+ "init_command",
16
+ "run_init",
17
+ "parse_agent_string",
18
+ "memory_app",
19
+ "verify_command",
20
+ # Workflow support
21
+ "WorkflowMixin",
22
+ "non_interactive_option",
23
+ "workflow_command_options",
24
+ "validate_required_defaults",
25
+ "create_non_interactive_workflow",
26
+ ]
@@ -0,0 +1,616 @@
1
+ """Analytics command for spec metrics and reporting.
2
+
3
+ Provides CLI commands for viewing spec analytics:
4
+ - show: Display completion metrics summary (default)
5
+ - cycles: Display cycle time statistics
6
+ - velocity: Display velocity trends
7
+ - spec: Display individual spec metrics
8
+ - export: Export analytics report
9
+ """
10
+
11
+ import json
12
+ from datetime import date, datetime
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ import typer
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from ..models.status_models import SpecState
21
+ from ..services.analytics_service import AnalyticsService
22
+ from ..services.spec_scanner import NotADoitProjectError, SpecNotFoundError
23
+
24
+ app = typer.Typer(help="Spec analytics and metrics dashboard")
25
+ console = Console()
26
+
27
+
28
+ def _get_status_emoji(status: SpecState) -> str:
29
+ """Get emoji for status display."""
30
+ emojis = {
31
+ SpecState.DRAFT: "[dim]📝[/dim]",
32
+ SpecState.IN_PROGRESS: "[yellow]🔄[/yellow]",
33
+ SpecState.COMPLETE: "[green]✅[/green]",
34
+ SpecState.APPROVED: "[cyan]🏆[/cyan]",
35
+ SpecState.ERROR: "[red]❌[/red]",
36
+ }
37
+ return emojis.get(status, "❓")
38
+
39
+
40
+ @app.callback(invoke_without_command=True)
41
+ def main(ctx: typer.Context):
42
+ """Spec analytics and metrics dashboard.
43
+
44
+ Run without arguments to show completion metrics summary.
45
+ """
46
+ if ctx.invoked_subcommand is None:
47
+ # Default to show command
48
+ show(json_output=False)
49
+
50
+
51
+ @app.command()
52
+ def show(
53
+ json_output: bool = typer.Option(
54
+ False, "--json", help="Output as JSON instead of table"
55
+ ),
56
+ ) -> None:
57
+ """Display completion metrics summary for all specs.
58
+
59
+ Shows total specs, status breakdown, and completion percentage.
60
+
61
+ Exit codes:
62
+ 0 - Success
63
+ 1 - No specs found
64
+ 2 - Not a doit project
65
+ """
66
+ try:
67
+ service = AnalyticsService()
68
+ summary = service.get_completion_summary()
69
+
70
+ if summary["total_specs"] == 0:
71
+ if json_output:
72
+ print(json.dumps({"success": False, "error": "No specs found"}))
73
+ else:
74
+ console.print(
75
+ "[yellow]No specifications found in specs/ directory.[/yellow]"
76
+ )
77
+ raise typer.Exit(code=1)
78
+
79
+ if json_output:
80
+ report = service.generate_report()
81
+ print(json.dumps(report.to_dict(), indent=2))
82
+ else:
83
+ _print_completion_summary(summary)
84
+
85
+ raise typer.Exit(code=0)
86
+
87
+ except NotADoitProjectError as e:
88
+ if json_output:
89
+ print(json.dumps({"success": False, "error": str(e)}))
90
+ else:
91
+ console.print(f"[red]Error:[/red] {e}")
92
+ raise typer.Exit(code=2)
93
+
94
+
95
+ def _print_completion_summary(summary: dict) -> None:
96
+ """Print completion metrics in Rich tables."""
97
+ console.print()
98
+ console.print("[bold]Spec Analytics[/bold]")
99
+ console.print()
100
+
101
+ # Summary table
102
+ summary_table = Table(title="Summary", show_header=True)
103
+ summary_table.add_column("Metric", style="bold")
104
+ summary_table.add_column("Value", justify="right")
105
+
106
+ summary_table.add_row("Total Specs", str(summary["total_specs"]))
107
+ summary_table.add_row(
108
+ "Completed",
109
+ str(summary["complete_count"] + summary["approved_count"]),
110
+ )
111
+ summary_table.add_row("In Progress", str(summary["in_progress_count"]))
112
+ summary_table.add_row("Draft", str(summary["draft_count"]))
113
+ summary_table.add_row("Completion %", f"{summary['completion_pct']}%")
114
+
115
+ console.print(summary_table)
116
+ console.print()
117
+
118
+ # Status breakdown table
119
+ breakdown_table = Table(title="Status Breakdown", show_header=True)
120
+ breakdown_table.add_column("Status")
121
+ breakdown_table.add_column("Count", justify="right")
122
+ breakdown_table.add_column("Percentage", justify="right")
123
+
124
+ total = summary["total_specs"]
125
+
126
+ status_data = [
127
+ (_get_status_emoji(SpecState.COMPLETE) + " Complete", summary["complete_count"]),
128
+ (_get_status_emoji(SpecState.APPROVED) + " Approved", summary["approved_count"]),
129
+ (_get_status_emoji(SpecState.IN_PROGRESS) + " Progress", summary["in_progress_count"]),
130
+ (_get_status_emoji(SpecState.DRAFT) + " Draft", summary["draft_count"]),
131
+ ]
132
+
133
+ for status_name, count in status_data:
134
+ pct = (count / total * 100) if total > 0 else 0
135
+ breakdown_table.add_row(status_name, str(count), f"{pct:.1f}%")
136
+
137
+ console.print(breakdown_table)
138
+ console.print()
139
+
140
+
141
+ @app.command()
142
+ def cycles(
143
+ days: int = typer.Option(30, "--days", "-d", help="Filter to last N days"),
144
+ since: Optional[str] = typer.Option(
145
+ None, "--since", "-s", help="Filter since date (YYYY-MM-DD)"
146
+ ),
147
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
148
+ ) -> None:
149
+ """Display cycle time statistics for completed specs.
150
+
151
+ Shows average, median, min, max, and standard deviation of cycle times.
152
+
153
+ Exit codes:
154
+ 0 - Success
155
+ 1 - No completed specs in period
156
+ 2 - Not a doit project
157
+ """
158
+ try:
159
+ service = AnalyticsService()
160
+
161
+ # Parse since date if provided
162
+ since_date: Optional[date] = None
163
+ filter_days: Optional[int] = days
164
+
165
+ if since:
166
+ try:
167
+ since_date = datetime.strptime(since, "%Y-%m-%d").date()
168
+ filter_days = None # since overrides days
169
+ except ValueError:
170
+ console.print(
171
+ f"[red]Error:[/red] Invalid date format '{since}'. Use YYYY-MM-DD."
172
+ )
173
+ raise typer.Exit(code=2)
174
+
175
+ stats, records = service.get_cycle_time_stats(days=filter_days, since=since_date)
176
+
177
+ if not records:
178
+ if json_output:
179
+ print(json.dumps({"success": False, "error": "No completed specs in period"}))
180
+ else:
181
+ console.print(
182
+ "[yellow]No completed specs found in the specified period.[/yellow]"
183
+ )
184
+ raise typer.Exit(code=1)
185
+
186
+ if json_output:
187
+ _print_cycles_json(stats, records)
188
+ else:
189
+ _print_cycles_tables(stats, records, days if not since else None, since)
190
+
191
+ raise typer.Exit(code=0)
192
+
193
+ except NotADoitProjectError as e:
194
+ if json_output:
195
+ print(json.dumps({"success": False, "error": str(e)}))
196
+ else:
197
+ console.print(f"[red]Error:[/red] {e}")
198
+ raise typer.Exit(code=2)
199
+
200
+
201
+ def _print_cycles_json(stats, records) -> None:
202
+ """Print cycle time data as JSON."""
203
+ output = {
204
+ "success": True,
205
+ "cycle_stats": {
206
+ "average_days": stats.average_days,
207
+ "median_days": stats.median_days,
208
+ "min_days": stats.min_days,
209
+ "max_days": stats.max_days,
210
+ "std_dev_days": stats.std_dev_days,
211
+ "sample_count": stats.sample_count,
212
+ },
213
+ "recent_completions": [
214
+ {
215
+ "name": r.feature_name,
216
+ "completed": r.end_date.isoformat(),
217
+ "cycle_days": r.days_to_complete,
218
+ }
219
+ for r in records[:10]
220
+ ],
221
+ }
222
+ print(json.dumps(output, indent=2))
223
+
224
+
225
+ def _print_cycles_tables(stats, records, days: Optional[int], since: Optional[str]) -> None:
226
+ """Print cycle time data in Rich tables."""
227
+ console.print()
228
+
229
+ # Title with filter info
230
+ if since:
231
+ title = f"Cycle Time Analysis (since {since})"
232
+ else:
233
+ title = f"Cycle Time Analysis (last {days} days)"
234
+
235
+ console.print(f"[bold]{title}[/bold]")
236
+ console.print()
237
+
238
+ # Statistics table
239
+ stats_table = Table(
240
+ title=f"Statistics (N={stats.sample_count} completed specs)",
241
+ show_header=True,
242
+ )
243
+ stats_table.add_column("Metric", style="bold")
244
+ stats_table.add_column("Value", justify="right")
245
+
246
+ stats_table.add_row("Average", f"{stats.average_days} days")
247
+ stats_table.add_row("Median", f"{stats.median_days} days")
248
+ stats_table.add_row("Minimum", f"{stats.min_days} day{'s' if stats.min_days != 1 else ''}")
249
+ stats_table.add_row("Maximum", f"{stats.max_days} days")
250
+ stats_table.add_row("Std Deviation", f"{stats.std_dev_days} days")
251
+
252
+ console.print(stats_table)
253
+ console.print()
254
+
255
+ # Recent completions table (top 10)
256
+ recent_table = Table(title="Recent Completions", show_header=True)
257
+ recent_table.add_column("Spec")
258
+ recent_table.add_column("Completed", justify="center")
259
+ recent_table.add_column("Cycle Time", justify="right")
260
+
261
+ for record in records[:10]:
262
+ recent_table.add_row(
263
+ record.feature_name,
264
+ record.end_date.isoformat(),
265
+ f"{record.days_to_complete} days",
266
+ )
267
+
268
+ console.print(recent_table)
269
+ console.print()
270
+
271
+
272
+ @app.command()
273
+ def velocity(
274
+ weeks: int = typer.Option(8, "--weeks", "-w", help="Number of weeks to display"),
275
+ format_type: str = typer.Option(
276
+ "table", "--format", "-f", help="Output format: table, json, csv"
277
+ ),
278
+ ) -> None:
279
+ """Display velocity trends over time.
280
+
281
+ Shows specs completed per week with visual indicators.
282
+
283
+ Exit codes:
284
+ 0 - Success
285
+ 1 - Insufficient data (< 2 weeks)
286
+ 2 - Not a doit project
287
+ """
288
+ try:
289
+ service = AnalyticsService()
290
+ velocity_data = service.get_velocity_data(weeks=weeks)
291
+
292
+ if len(velocity_data) < 2:
293
+ if format_type == "json":
294
+ print(json.dumps({"success": False, "error": "Insufficient data"}))
295
+ else:
296
+ console.print(
297
+ "[yellow]Insufficient data for velocity trends. "
298
+ "Need at least 2 weeks of history.[/yellow]"
299
+ )
300
+ if velocity_data:
301
+ console.print(f"Available data points: {len(velocity_data)}")
302
+ raise typer.Exit(code=1)
303
+
304
+ if format_type == "json":
305
+ _print_velocity_json(velocity_data)
306
+ elif format_type == "csv":
307
+ _print_velocity_csv(velocity_data)
308
+ else:
309
+ _print_velocity_table(velocity_data, weeks)
310
+
311
+ raise typer.Exit(code=0)
312
+
313
+ except NotADoitProjectError as e:
314
+ if format_type == "json":
315
+ print(json.dumps({"success": False, "error": str(e)}))
316
+ else:
317
+ console.print(f"[red]Error:[/red] {e}")
318
+ raise typer.Exit(code=2)
319
+
320
+
321
+ def _print_velocity_json(velocity_data) -> None:
322
+ """Print velocity data as JSON."""
323
+ output = {
324
+ "success": True,
325
+ "velocity": [
326
+ {
327
+ "week": v.week_key,
328
+ "completed": v.specs_completed,
329
+ "specs": v.spec_names,
330
+ }
331
+ for v in velocity_data
332
+ ],
333
+ }
334
+ print(json.dumps(output, indent=2))
335
+
336
+
337
+ def _print_velocity_csv(velocity_data) -> None:
338
+ """Print velocity data as CSV."""
339
+ print("week,completed")
340
+ for v in velocity_data:
341
+ print(f"{v.week_key},{v.specs_completed}")
342
+
343
+
344
+ def _print_velocity_table(velocity_data, weeks: int) -> None:
345
+ """Print velocity data in Rich table with bar visualization."""
346
+ console.print()
347
+ console.print(f"[bold]Velocity Trends (last {weeks} weeks)[/bold]")
348
+ console.print()
349
+
350
+ # Find max for bar scaling
351
+ max_completed = max((v.specs_completed for v in velocity_data), default=1)
352
+
353
+ velocity_table = Table(show_header=True)
354
+ velocity_table.add_column("Week", style="bold")
355
+ velocity_table.add_column("Completed", justify="right")
356
+ velocity_table.add_column("Trend", min_width=35)
357
+
358
+ for v in velocity_data:
359
+ # Create bar visualization
360
+ bar_width = int((v.specs_completed / max_completed) * 30) if max_completed > 0 else 0
361
+ bar = "█" * bar_width
362
+
363
+ velocity_table.add_row(
364
+ v.week_key,
365
+ str(v.specs_completed),
366
+ f"[green]{bar}[/green]",
367
+ )
368
+
369
+ console.print(velocity_table)
370
+
371
+ # Calculate and show average
372
+ total = sum(v.specs_completed for v in velocity_data)
373
+ avg = total / len(velocity_data) if velocity_data else 0
374
+ console.print()
375
+ console.print(f"Average: {avg:.1f} specs/week")
376
+ console.print()
377
+
378
+
379
+ @app.command()
380
+ def spec(
381
+ spec_name: str = typer.Argument(..., help="Spec directory name"),
382
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
383
+ ) -> None:
384
+ """Display detailed metrics for a specific spec.
385
+
386
+ Shows status, dates, cycle time, and phase timeline.
387
+
388
+ Exit codes:
389
+ 0 - Success
390
+ 1 - Spec not found
391
+ 2 - Not a doit project
392
+ """
393
+ try:
394
+ service = AnalyticsService()
395
+
396
+ try:
397
+ metadata = service.get_spec_details(spec_name)
398
+ except SpecNotFoundError:
399
+ # Try to find similar specs
400
+ available = service.list_all_spec_names()
401
+ matches = [n for n in available if spec_name.lower() in n.lower()]
402
+
403
+ if json_output:
404
+ print(json.dumps({
405
+ "success": False,
406
+ "error": f"Spec '{spec_name}' not found",
407
+ "suggestions": matches[:5] if matches else available[:5],
408
+ }))
409
+ else:
410
+ console.print(f"[red]Error:[/red] Spec '{spec_name}' not found.")
411
+ if matches:
412
+ console.print("\nDid you mean:")
413
+ for m in matches[:5]:
414
+ console.print(f" - {m}")
415
+ else:
416
+ console.print("\nAvailable specs:")
417
+ for a in available[:5]:
418
+ console.print(f" - {a}")
419
+ raise typer.Exit(code=1)
420
+
421
+ if json_output:
422
+ _print_spec_json(metadata)
423
+ else:
424
+ _print_spec_details(metadata)
425
+
426
+ raise typer.Exit(code=0)
427
+
428
+ except NotADoitProjectError as e:
429
+ if json_output:
430
+ print(json.dumps({"success": False, "error": str(e)}))
431
+ else:
432
+ console.print(f"[red]Error:[/red] {e}")
433
+ raise typer.Exit(code=2)
434
+
435
+
436
+ def _print_spec_json(metadata) -> None:
437
+ """Print spec details as JSON."""
438
+ output = {
439
+ "success": True,
440
+ "spec": {
441
+ "name": metadata.name,
442
+ "status": metadata.status.display_name,
443
+ "created_at": metadata.created_at.isoformat() if metadata.created_at else None,
444
+ "completed_at": metadata.completed_at.isoformat() if metadata.completed_at else None,
445
+ "cycle_time_days": metadata.cycle_time_days,
446
+ "current_phase": metadata.current_phase,
447
+ "days_in_progress": metadata.days_in_progress,
448
+ "path": str(metadata.path) if metadata.path else None,
449
+ },
450
+ }
451
+ print(json.dumps(output, indent=2))
452
+
453
+
454
+ def _print_spec_details(metadata) -> None:
455
+ """Print spec details in Rich tables."""
456
+ console.print()
457
+ console.print(f"[bold]Spec Details: {metadata.name}[/bold]")
458
+ console.print()
459
+
460
+ # Details table
461
+ details_table = Table(show_header=True)
462
+ details_table.add_column("Field", style="bold")
463
+ details_table.add_column("Value")
464
+
465
+ status_display = f"{_get_status_emoji(metadata.status)} {metadata.status.display_name}"
466
+ details_table.add_row("Status", status_display)
467
+ details_table.add_row(
468
+ "Created",
469
+ metadata.created_at.isoformat() if metadata.created_at else "Unknown",
470
+ )
471
+ details_table.add_row(
472
+ "Completed",
473
+ metadata.completed_at.isoformat() if metadata.completed_at else "-",
474
+ )
475
+
476
+ if metadata.cycle_time_days is not None:
477
+ details_table.add_row("Cycle Time", f"{metadata.cycle_time_days} days")
478
+ elif metadata.days_in_progress > 0:
479
+ details_table.add_row("Days In Progress", str(metadata.days_in_progress))
480
+
481
+ details_table.add_row("Current Phase", metadata.current_phase)
482
+ details_table.add_row("Path", str(metadata.path) if metadata.path else "-")
483
+
484
+ console.print(details_table)
485
+ console.print()
486
+
487
+ # Timeline table (if we have dates)
488
+ if metadata.created_at:
489
+ timeline_table = Table(title="Timeline", show_header=True)
490
+ timeline_table.add_column("Date")
491
+ timeline_table.add_column("Event")
492
+
493
+ timeline_table.add_row(
494
+ metadata.created_at.isoformat(),
495
+ "Spec created (Draft)",
496
+ )
497
+
498
+ if metadata.status in (SpecState.IN_PROGRESS, SpecState.COMPLETE, SpecState.APPROVED):
499
+ # Estimate start date (not precise, but helpful)
500
+ timeline_table.add_row("-", "Started (In Progress)")
501
+
502
+ if metadata.completed_at:
503
+ timeline_table.add_row(
504
+ metadata.completed_at.isoformat(),
505
+ "Completed",
506
+ )
507
+
508
+ console.print(timeline_table)
509
+ console.print()
510
+
511
+
512
+ @app.command()
513
+ def export(
514
+ format_type: str = typer.Option(
515
+ "markdown", "--format", "-f", help="Export format: markdown, json"
516
+ ),
517
+ output_path: Optional[Path] = typer.Option(
518
+ None, "--output", "-o", help="Output file path"
519
+ ),
520
+ ) -> None:
521
+ """Export analytics report to file.
522
+
523
+ Creates a report in .doit/reports/ by default.
524
+
525
+ Exit codes:
526
+ 0 - Success
527
+ 1 - Export failed
528
+ 2 - Not a doit project
529
+ """
530
+ try:
531
+ service = AnalyticsService()
532
+ report = service.generate_report()
533
+
534
+ # Determine output path
535
+ if output_path is None:
536
+ reports_dir = service.project_root / ".doit" / "reports"
537
+ reports_dir.mkdir(parents=True, exist_ok=True)
538
+
539
+ timestamp = datetime.now().strftime("%Y-%m-%d")
540
+ ext = "json" if format_type == "json" else "md"
541
+ output_path = reports_dir / f"analytics-{timestamp}.{ext}"
542
+
543
+ # Generate content
544
+ if format_type == "json":
545
+ content = json.dumps(report.to_dict(), indent=2)
546
+ else:
547
+ content = _generate_markdown_report(report)
548
+
549
+ # Write file
550
+ output_path.parent.mkdir(parents=True, exist_ok=True)
551
+ output_path.write_text(content, encoding="utf-8")
552
+
553
+ console.print(f"[green]✓[/green] Analytics report exported to {output_path}")
554
+ raise typer.Exit(code=0)
555
+
556
+ except NotADoitProjectError as e:
557
+ console.print(f"[red]Error:[/red] {e}")
558
+ raise typer.Exit(code=2)
559
+ except (OSError, IOError) as e:
560
+ console.print(f"[red]Error:[/red] Failed to export report: {e}")
561
+ raise typer.Exit(code=1)
562
+
563
+
564
+ def _generate_markdown_report(report) -> str:
565
+ """Generate markdown content for analytics report."""
566
+ lines = [
567
+ f"# Analytics Report - {report.project_root.name}",
568
+ "",
569
+ f"Generated: {report.generated_at.strftime('%Y-%m-%d %H:%M:%S')}",
570
+ "",
571
+ "## Summary",
572
+ "",
573
+ f"- Total Specs: {report.total_specs}",
574
+ f"- Completion Rate: {report.completion_pct}%",
575
+ ]
576
+
577
+ if report.cycle_stats:
578
+ lines.append(f"- Average Cycle Time: {report.cycle_stats.average_days} days")
579
+
580
+ lines.extend([
581
+ "",
582
+ "## Status Breakdown",
583
+ "",
584
+ "| Status | Count |",
585
+ "|--------|-------|",
586
+ ])
587
+
588
+ for status, count in report.by_status.items():
589
+ lines.append(f"| {status.display_name} | {count} |")
590
+
591
+ if report.cycle_stats:
592
+ lines.extend([
593
+ "",
594
+ "## Cycle Time Statistics",
595
+ "",
596
+ "| Metric | Value |",
597
+ "|--------|-------|",
598
+ f"| Average | {report.cycle_stats.average_days} days |",
599
+ f"| Median | {report.cycle_stats.median_days} days |",
600
+ f"| Min | {report.cycle_stats.min_days} days |",
601
+ f"| Max | {report.cycle_stats.max_days} days |",
602
+ f"| Sample Size | {report.cycle_stats.sample_count} |",
603
+ ])
604
+
605
+ if report.velocity:
606
+ lines.extend([
607
+ "",
608
+ "## Velocity Trends",
609
+ "",
610
+ "| Week | Completed |",
611
+ "|------|-----------|",
612
+ ])
613
+ for v in report.velocity[:12]:
614
+ lines.append(f"| {v.week_key} | {v.specs_completed} |")
615
+
616
+ return "\n".join(lines)