devrel-origin 0.2.14__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 (98) hide show
  1. devrel_origin/__init__.py +15 -0
  2. devrel_origin/cli/__init__.py +92 -0
  3. devrel_origin/cli/_common.py +243 -0
  4. devrel_origin/cli/analytics.py +28 -0
  5. devrel_origin/cli/argus.py +497 -0
  6. devrel_origin/cli/auth.py +227 -0
  7. devrel_origin/cli/config.py +108 -0
  8. devrel_origin/cli/content.py +259 -0
  9. devrel_origin/cli/cost.py +108 -0
  10. devrel_origin/cli/cro.py +298 -0
  11. devrel_origin/cli/deliverables.py +65 -0
  12. devrel_origin/cli/docs.py +91 -0
  13. devrel_origin/cli/doctor.py +178 -0
  14. devrel_origin/cli/experiment.py +29 -0
  15. devrel_origin/cli/growth.py +97 -0
  16. devrel_origin/cli/init.py +472 -0
  17. devrel_origin/cli/intel.py +27 -0
  18. devrel_origin/cli/kb.py +96 -0
  19. devrel_origin/cli/listen.py +31 -0
  20. devrel_origin/cli/marketing.py +66 -0
  21. devrel_origin/cli/migrate.py +45 -0
  22. devrel_origin/cli/run.py +46 -0
  23. devrel_origin/cli/sales.py +57 -0
  24. devrel_origin/cli/schedule.py +62 -0
  25. devrel_origin/cli/synthesize.py +28 -0
  26. devrel_origin/cli/triage.py +29 -0
  27. devrel_origin/cli/video.py +35 -0
  28. devrel_origin/core/__init__.py +58 -0
  29. devrel_origin/core/agent_config.py +75 -0
  30. devrel_origin/core/argus.py +964 -0
  31. devrel_origin/core/atlas.py +1450 -0
  32. devrel_origin/core/base.py +372 -0
  33. devrel_origin/core/cyra.py +563 -0
  34. devrel_origin/core/dex.py +708 -0
  35. devrel_origin/core/echo.py +614 -0
  36. devrel_origin/core/growth/__init__.py +27 -0
  37. devrel_origin/core/growth/recommendations.py +219 -0
  38. devrel_origin/core/growth/target_kinds.py +51 -0
  39. devrel_origin/core/iris.py +513 -0
  40. devrel_origin/core/kai.py +1367 -0
  41. devrel_origin/core/llm.py +542 -0
  42. devrel_origin/core/llm_backends.py +274 -0
  43. devrel_origin/core/mox.py +514 -0
  44. devrel_origin/core/nova.py +349 -0
  45. devrel_origin/core/pax.py +1205 -0
  46. devrel_origin/core/rex.py +532 -0
  47. devrel_origin/core/sage.py +486 -0
  48. devrel_origin/core/sentinel.py +385 -0
  49. devrel_origin/core/types.py +98 -0
  50. devrel_origin/core/video/__init__.py +22 -0
  51. devrel_origin/core/video/assembler.py +131 -0
  52. devrel_origin/core/video/browser_recorder.py +118 -0
  53. devrel_origin/core/video/desktop_recorder.py +254 -0
  54. devrel_origin/core/video/overlay_renderer.py +143 -0
  55. devrel_origin/core/video/script_parser.py +147 -0
  56. devrel_origin/core/video/tts_engine.py +82 -0
  57. devrel_origin/core/vox.py +268 -0
  58. devrel_origin/core/watchdog.py +321 -0
  59. devrel_origin/project/__init__.py +1 -0
  60. devrel_origin/project/config.py +75 -0
  61. devrel_origin/project/cost_sink.py +61 -0
  62. devrel_origin/project/init.py +104 -0
  63. devrel_origin/project/paths.py +75 -0
  64. devrel_origin/project/state.py +241 -0
  65. devrel_origin/project/templates/__init__.py +4 -0
  66. devrel_origin/project/templates/config.toml +24 -0
  67. devrel_origin/project/templates/devrel.gitignore +10 -0
  68. devrel_origin/project/templates/slop-blocklist.md +45 -0
  69. devrel_origin/project/templates/style.md +24 -0
  70. devrel_origin/project/templates/voice.md +29 -0
  71. devrel_origin/quality/__init__.py +66 -0
  72. devrel_origin/quality/editorial.py +357 -0
  73. devrel_origin/quality/persona.py +84 -0
  74. devrel_origin/quality/readability.py +148 -0
  75. devrel_origin/quality/slop.py +167 -0
  76. devrel_origin/quality/style.py +110 -0
  77. devrel_origin/quality/voice.py +15 -0
  78. devrel_origin/tools/__init__.py +9 -0
  79. devrel_origin/tools/analytics.py +304 -0
  80. devrel_origin/tools/api_client.py +393 -0
  81. devrel_origin/tools/apollo_client.py +305 -0
  82. devrel_origin/tools/code_validator.py +428 -0
  83. devrel_origin/tools/github_tools.py +297 -0
  84. devrel_origin/tools/instantly_client.py +412 -0
  85. devrel_origin/tools/kb_harvester.py +340 -0
  86. devrel_origin/tools/mcp_server.py +578 -0
  87. devrel_origin/tools/notifications.py +245 -0
  88. devrel_origin/tools/run_report.py +193 -0
  89. devrel_origin/tools/scheduler.py +231 -0
  90. devrel_origin/tools/search_tools.py +321 -0
  91. devrel_origin/tools/self_improve.py +168 -0
  92. devrel_origin/tools/sheets.py +236 -0
  93. devrel_origin-0.2.14.dist-info/METADATA +354 -0
  94. devrel_origin-0.2.14.dist-info/RECORD +98 -0
  95. devrel_origin-0.2.14.dist-info/WHEEL +5 -0
  96. devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
  97. devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
  98. devrel_origin-0.2.14.dist-info/top_level.txt +1 -0
@@ -0,0 +1,298 @@
1
+ """`devrel cro ...`: CRO auditor verbs (Cyra)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import sqlite3
9
+ from datetime import date, timedelta
10
+ from pathlib import Path
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from devrel_origin.cli._common import find_paths_or_exit
17
+ from devrel_origin.core.cyra import Cyra
18
+ from devrel_origin.core.llm import LLMClient
19
+ from devrel_origin.tools.api_client import PostHogClient
20
+
21
+ cro_app = typer.Typer(
22
+ name="cro",
23
+ help="CRO auditor (Cyra). Funnel drop-offs + LLM-generated A/B hypotheses.",
24
+ no_args_is_help=True,
25
+ )
26
+
27
+ _console = Console()
28
+
29
+
30
+ def _build_cyra(db_path: Path) -> Cyra:
31
+ """Construct Cyra with clients from environment variables. Patched in unit tests."""
32
+ posthog = PostHogClient(
33
+ api_key=os.environ.get("POSTHOG_API_KEY", ""),
34
+ project_id=os.environ.get("POSTHOG_PROJECT_ID", ""),
35
+ )
36
+ llm = LLMClient(api_key=os.environ.get("ANTHROPIC_API_KEY", ""))
37
+ llm.set_agent("cyra")
38
+ return Cyra(
39
+ posthog_client=posthog,
40
+ llm_client=llm,
41
+ db_path=db_path,
42
+ )
43
+
44
+
45
+ @cro_app.command("report")
46
+ def report(
47
+ since: str = typer.Option("7d", "--since", help="Window: 7d, 30d, 90d"),
48
+ push: bool = typer.Option(False, "--push", help="Email/Telegram the report"),
49
+ format: str = typer.Option("markdown", "--format", help="markdown|json"),
50
+ ) -> None:
51
+ """Run a Cyra cycle and persist Recommendation rows + Mox briefs."""
52
+ paths = find_paths_or_exit(_console)
53
+ _days = int(since.rstrip("d"))
54
+ period_end = date.today().isoformat()
55
+
56
+ # Insert an analytics_reports row to anchor the FK from analytics_recommendations.
57
+ # The CLI is the report producer here; Cyra emits recommendation rows attached
58
+ # to this report_id.
59
+ db_path = paths.state_db
60
+ period_start = (date.today() - timedelta(days=_days)).isoformat()
61
+ with sqlite3.connect(db_path) as conn:
62
+ cur = conn.execute(
63
+ "INSERT INTO analytics_reports (period_start, period_end, report_json) "
64
+ "VALUES (?, ?, ?)",
65
+ (period_start, period_end, "{}"),
66
+ )
67
+ report_id = cur.lastrowid
68
+ conn.commit()
69
+
70
+ cyra = _build_cyra(db_path)
71
+
72
+ async def _run():
73
+ return await cyra.execute(
74
+ period_end=period_end,
75
+ report_id=report_id,
76
+ page_html_by_url={},
77
+ iris_themes=[],
78
+ sage_friction=[],
79
+ deliverables_dir=paths.deliverables_dir,
80
+ )
81
+
82
+ result = asyncio.run(_run())
83
+
84
+ if format == "json":
85
+ _console.print(
86
+ json.dumps(
87
+ {
88
+ "period_end": result.period_end,
89
+ "funnel_id": result.funnel_id,
90
+ "dropoffs": [
91
+ {
92
+ "from_step": d.from_step,
93
+ "to_step": d.to_step,
94
+ "conversion_rate": d.conversion_rate,
95
+ "pp_delta_vs_prior": d.pp_delta_vs_prior,
96
+ "sample_size": d.sample_size,
97
+ }
98
+ for d in result.dropoffs
99
+ ],
100
+ "recommendations": [
101
+ {
102
+ "action": r.action,
103
+ "target": r.target,
104
+ "confidence": r.confidence,
105
+ "source_ids": r.source_ids,
106
+ }
107
+ for r in result.recommendations
108
+ ],
109
+ },
110
+ indent=2,
111
+ default=str,
112
+ )
113
+ )
114
+ return
115
+
116
+ # Markdown table via Rich
117
+ table = Table(title=f"Cyra report: {period_end}")
118
+ table.add_column("From -> To", style="cyan")
119
+ table.add_column("Conv", justify="right")
120
+ table.add_column("WoW Delta", justify="right")
121
+ table.add_column("Sample", justify="right")
122
+ for d in result.dropoffs:
123
+ table.add_row(
124
+ f"{d.from_step} -> {d.to_step}",
125
+ f"{d.conversion_rate:.1%}",
126
+ f"{d.pp_delta_vs_prior:+.1%}",
127
+ f"{d.sample_size:,}",
128
+ )
129
+ _console.print(table)
130
+ _console.print(f"[green]Wrote {len(result.recommendations)} recommendation(s).[/green]")
131
+ if push:
132
+ _console.print("[yellow]--push not yet implemented for cro; printed-only.[/yellow]")
133
+
134
+
135
+ @cro_app.command("history")
136
+ def history(
137
+ funnel_step: str = typer.Argument(..., help="Funnel step name to track"),
138
+ limit: int = typer.Option(20, "--limit"),
139
+ ) -> None:
140
+ """Show conversion-rate trajectory for a funnel step across reports."""
141
+ paths = find_paths_or_exit(_console)
142
+ db_path = paths.devrel_dir / "state.db"
143
+ if not db_path.is_file():
144
+ _console.print("[yellow]No state.db yet, run `devrel cro report` first.[/yellow]")
145
+ raise typer.Exit(code=0)
146
+
147
+ table = Table(title=f"History: {funnel_step}")
148
+ table.add_column("Period", style="cyan")
149
+ table.add_column("Conv", justify="right")
150
+ table.add_column("Sample", justify="right")
151
+
152
+ with sqlite3.connect(db_path) as conn:
153
+ cur = conn.execute(
154
+ """
155
+ SELECT period_end, conversion_rate, sample_size
156
+ FROM cro_funnel_metrics
157
+ WHERE step_index = (
158
+ SELECT MIN(step_index) FROM cro_funnel_metrics
159
+ WHERE funnel_id IN (SELECT DISTINCT funnel_id FROM cro_funnel_metrics)
160
+ )
161
+ ORDER BY period_end DESC
162
+ LIMIT ?
163
+ """,
164
+ (limit,),
165
+ )
166
+ for period_end, conv, sample in cur:
167
+ table.add_row(period_end, f"{(conv or 0):.1%}", f"{(sample or 0):,}")
168
+
169
+ _console.print(table)
170
+
171
+
172
+ @cro_app.command("diff")
173
+ def diff(
174
+ period_a: str = typer.Argument(..., help="Earlier ISO period"),
175
+ period_b: str = typer.Argument(..., help="Later ISO period"),
176
+ ) -> None:
177
+ """Per-step conversion delta between two CRO reports."""
178
+ paths = find_paths_or_exit(_console)
179
+ db_path = paths.devrel_dir / "state.db"
180
+ if not db_path.is_file():
181
+ _console.print("[yellow]No state.db yet.[/yellow]")
182
+ raise typer.Exit(code=0)
183
+
184
+ table = Table(title=f"CRO diff: {period_a} -> {period_b}")
185
+ table.add_column("Funnel", style="cyan")
186
+ table.add_column("Step", justify="right")
187
+ table.add_column(f"{period_a}", justify="right")
188
+ table.add_column(f"{period_b}", justify="right")
189
+ table.add_column("Delta pp", justify="right")
190
+
191
+ with sqlite3.connect(db_path) as conn:
192
+ cur = conn.execute(
193
+ """
194
+ SELECT a.funnel_id, a.step_index, a.conversion_rate, b.conversion_rate
195
+ FROM cro_funnel_metrics a
196
+ JOIN cro_funnel_metrics b
197
+ ON a.funnel_id = b.funnel_id AND a.step_index = b.step_index
198
+ WHERE a.period_end = ? AND b.period_end = ?
199
+ ORDER BY a.funnel_id, a.step_index
200
+ """,
201
+ (period_a, period_b),
202
+ )
203
+ for funnel_id, step_index, conv_a, conv_b in cur:
204
+ delta = (conv_b or 0) - (conv_a or 0)
205
+ table.add_row(
206
+ funnel_id,
207
+ str(step_index),
208
+ f"{(conv_a or 0):.1%}",
209
+ f"{(conv_b or 0):.1%}",
210
+ f"{delta:+.1%}",
211
+ )
212
+
213
+ _console.print(table)
214
+
215
+
216
+ @cro_app.command("calibration")
217
+ def calibration() -> None:
218
+ """Score historical CRO recommendations against subsequent funnel data."""
219
+ paths = find_paths_or_exit(_console)
220
+ db_path = paths.devrel_dir / "state.db"
221
+ if not db_path.is_file():
222
+ _console.print("[yellow]No state.db yet.[/yellow]")
223
+ raise typer.Exit(code=0)
224
+
225
+ from devrel_origin.core.growth.recommendations import calibrate
226
+ from devrel_origin.core.growth.target_kinds import Pillar
227
+
228
+ def _score_outcome(rec) -> str:
229
+ """Did conversion improve at this funnel step after the rec was applied?"""
230
+ if rec.applied_at is None:
231
+ return "unchanged"
232
+ with sqlite3.connect(db_path) as conn:
233
+ cur = conn.execute(
234
+ """
235
+ SELECT conversion_rate FROM cro_funnel_metrics
236
+ WHERE funnel_id IN (SELECT DISTINCT funnel_id FROM cro_funnel_metrics)
237
+ AND period_end >= ?
238
+ ORDER BY period_end ASC LIMIT 2
239
+ """,
240
+ (rec.applied_at[:10],),
241
+ )
242
+ rates = [row[0] for row in cur.fetchall()]
243
+ if len(rates) < 2:
244
+ return "unchanged"
245
+ return (
246
+ "improved"
247
+ if rates[1] > rates[0]
248
+ else ("regressed" if rates[1] < rates[0] else "unchanged")
249
+ )
250
+
251
+ result = calibrate(db_path, Pillar.CRO, outcome_scorer=_score_outcome)
252
+
253
+ if not result:
254
+ _console.print("[yellow]No applied CRO recommendations yet.[/yellow]")
255
+ return
256
+
257
+ table = Table(title="CRO calibration")
258
+ table.add_column("Action", style="cyan")
259
+ table.add_column("Applied", justify="right")
260
+ table.add_column("Hit rate", justify="right")
261
+ table.add_column("Lift vs coinflip", justify="right")
262
+ for action, stats in result.items():
263
+ table.add_row(
264
+ action,
265
+ str(stats["applied_count"]),
266
+ f"{stats['hit_rate']:.1%}",
267
+ f"{stats['lift_vs_coinflip']:+.1%}",
268
+ )
269
+ _console.print(table)
270
+
271
+
272
+ @cro_app.command("funnel")
273
+ def funnel(
274
+ show_detected: bool = typer.Option(
275
+ False, "--show-detected", help="Show what auto-detect picked"
276
+ ),
277
+ days: int = typer.Option(7, "--days"),
278
+ ) -> None:
279
+ """Inspect the current (auto-detected or configured) CRO funnel."""
280
+ paths = find_paths_or_exit(_console)
281
+ cyra = _build_cyra(paths.devrel_dir / "state.db")
282
+
283
+ async def _run():
284
+ return await cyra._autodetect_funnel(days=days)
285
+
286
+ funnel_events = asyncio.run(_run())
287
+
288
+ table = Table(title=f"Cyra funnel (auto-detected, {days}d)")
289
+ table.add_column("#", justify="right")
290
+ table.add_column("Event", style="cyan")
291
+ for i, ev in enumerate(funnel_events):
292
+ table.add_row(str(i), ev)
293
+
294
+ _console.print(table)
295
+ if show_detected:
296
+ _console.print(
297
+ "[dim]Override via `[growth].cro_funnel = [...]` in .devrel/config.toml[/dim]"
298
+ )
@@ -0,0 +1,65 @@
1
+ """`devrel deliverables {list, show}` — browse generated artifacts.
2
+
3
+ Lists / cats files under .devrel/deliverables/ — the canonical output
4
+ directory used by `devrel content draft|audit` and the agent pipeline.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ from devrel_origin.cli._common import find_paths_or_exit
13
+
14
+ console = Console()
15
+
16
+ deliverables_app = typer.Typer(
17
+ name="deliverables",
18
+ help="List and inspect generated content/artifacts under .devrel/deliverables/.",
19
+ no_args_is_help=True,
20
+ add_completion=False,
21
+ )
22
+
23
+
24
+ @deliverables_app.command("list")
25
+ def list_files() -> None:
26
+ """List all deliverable files (newest first)."""
27
+ paths = find_paths_or_exit(console)
28
+ if not paths.deliverables_dir.exists():
29
+ console.print("[yellow]No deliverables directory yet.[/yellow]")
30
+ return
31
+ files = sorted(
32
+ paths.deliverables_dir.rglob("*"),
33
+ key=lambda p: p.stat().st_mtime if p.is_file() else 0,
34
+ reverse=True,
35
+ )
36
+ files = [p for p in files if p.is_file()]
37
+ if not files:
38
+ console.print("[yellow]No deliverables yet.[/yellow]")
39
+ return
40
+ for p in files:
41
+ rel = p.relative_to(paths.deliverables_dir)
42
+ size = p.stat().st_size
43
+ console.print(f" [dim]{size:>7d}[/dim] {rel}")
44
+ console.print(f"\n[green]{len(files)} file(s)[/green]")
45
+
46
+
47
+ @deliverables_app.command("show")
48
+ def show(
49
+ name: str = typer.Argument(..., help="Filename (or substring) to display."),
50
+ ) -> None:
51
+ """Print the contents of a deliverable file (substring match on name)."""
52
+ paths = find_paths_or_exit(console)
53
+ if not paths.deliverables_dir.exists():
54
+ console.print("[yellow]No deliverables directory yet.[/yellow]")
55
+ raise typer.Exit(code=1)
56
+ matches = [p for p in paths.deliverables_dir.rglob("*") if p.is_file() and name in p.name]
57
+ if not matches:
58
+ console.print(f"[red]No deliverable matching '{name}'[/red]")
59
+ raise typer.Exit(code=1)
60
+ if len(matches) > 1:
61
+ console.print(f"[yellow]Multiple matches for '{name}':[/yellow]")
62
+ for p in matches:
63
+ console.print(f" {p.relative_to(paths.deliverables_dir)}")
64
+ raise typer.Exit(code=1)
65
+ typer.echo(matches[0].read_text())
@@ -0,0 +1,91 @@
1
+ """`devrel docs build` - AST-based docs via Dex."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from devrel_origin.cli._common import build_atlas_or_exit, find_paths_or_exit, render_result
14
+
15
+ console = Console()
16
+
17
+ docs_app = typer.Typer(
18
+ name="docs",
19
+ help="Documentation generation.",
20
+ no_args_is_help=True,
21
+ add_completion=False,
22
+ )
23
+
24
+
25
+ def _persist_dex_output(output: dict[str, Any], deliverables_dir: Path) -> list[Path]:
26
+ """Write Dex's architecture / API / summary / modules outputs to disk.
27
+
28
+ Returns the list of files actually written (skips empty values). Used by
29
+ `devrel docs build` so users don't have to invoke with --json and split
30
+ the JSON blob themselves; Dex's architecture_doc is ~36KB and api_reference
31
+ is ~270KB on a real codebase, both unreadable through the truncated render.
32
+ """
33
+ deliverables_dir.mkdir(parents=True, exist_ok=True)
34
+ written: list[Path] = []
35
+
36
+ def _write_text(name: str, value: Any) -> None:
37
+ if not value or not isinstance(value, str):
38
+ return
39
+ path = deliverables_dir / name
40
+ path.write_text(value)
41
+ written.append(path)
42
+
43
+ _write_text("dex-architecture.md", output.get("architecture_doc"))
44
+ _write_text("dex-api-reference.md", output.get("api_reference"))
45
+ _write_text("dex-summary.md", output.get("llm_summary"))
46
+
47
+ # Modules + languages travel as structured data, so persist as JSON for
48
+ # downstream tooling. Skip if absent or empty.
49
+ modules = output.get("modules")
50
+ if modules:
51
+ manifest = {
52
+ "languages": output.get("languages", {}),
53
+ "modules": modules,
54
+ "status": output.get("status", "generated"),
55
+ }
56
+ path = deliverables_dir / "dex-modules.json"
57
+ path.write_text(json.dumps(manifest, indent=2, default=str))
58
+ written.append(path)
59
+
60
+ return written
61
+
62
+
63
+ @docs_app.command("build")
64
+ def build(
65
+ json_output: bool = typer.Option(False, "--json"),
66
+ ) -> None:
67
+ """Build architecture docs + API reference from source via Dex.
68
+
69
+ Successful runs persist architecture_doc / api_reference / llm_summary
70
+ to .devrel/deliverables/dex-*.md so users don't have to pipe --json and
71
+ split the blob manually.
72
+ """
73
+ paths = find_paths_or_exit(console)
74
+ atlas = build_atlas_or_exit(paths, console)
75
+
76
+ async def _do() -> None:
77
+ result = await atlas.run_single_task("dex", "Build architecture docs and API reference")
78
+ if result.success and isinstance(result.output, dict):
79
+ written = _persist_dex_output(result.output, paths.deliverables_dir)
80
+ if written and not json_output:
81
+ console.print(f"[green]✓[/green] dex completed; wrote {len(written)} file(s):")
82
+ for p in written:
83
+ try:
84
+ rel = p.relative_to(paths.root)
85
+ console.print(f" [dim]-[/dim] {rel}")
86
+ except ValueError:
87
+ console.print(f" [dim]-[/dim] {p}")
88
+ return
89
+ render_result(result, console, json_output=json_output)
90
+
91
+ asyncio.run(_do())
@@ -0,0 +1,178 @@
1
+ """`devrel doctor` — health checks for the current project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from dataclasses import asdict, dataclass
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from devrel_origin.project.config import ConfigError, ProjectConfig
14
+ from devrel_origin.project.paths import (
15
+ ProjectNotFoundError,
16
+ ProjectPaths,
17
+ find_devrel_root,
18
+ )
19
+ from devrel_origin.project.state import SCHEMA_VERSION, get_schema_version
20
+
21
+ console = Console()
22
+
23
+ # LLM key requirement is one-of: Anthropic direct OR OpenRouter (multi-provider).
24
+ LLM_KEY_OPTIONS: tuple[str, ...] = ("ANTHROPIC_API_KEY", "OPENROUTER_API_KEY")
25
+ OPTIONAL_ENV = (
26
+ "GITHUB_TOKEN",
27
+ "FIRECRAWL_API_KEY",
28
+ "BRAVE_API_KEY",
29
+ "INSTANTLY_API_KEY",
30
+ "APOLLO_API_KEY",
31
+ "OPENAI_API_KEY",
32
+ "TELEGRAM_BOT_TOKEN",
33
+ )
34
+
35
+
36
+ @dataclass
37
+ class CheckResult:
38
+ name: str
39
+ status: str # 'pass' | 'warn' | 'fail'
40
+ detail: str = ""
41
+
42
+
43
+ def _run_checks(paths: ProjectPaths) -> list[CheckResult]:
44
+ results: list[CheckResult] = []
45
+
46
+ # Python version.
47
+ py = sys.version_info
48
+ py_str = f"{py.major}.{py.minor}.{py.micro}"
49
+ if (py.major, py.minor) >= (3, 12):
50
+ results.append(CheckResult("python_version", "pass", py_str))
51
+ else:
52
+ results.append(CheckResult("python_version", "fail", f"{py_str} (requires >=3.12)"))
53
+
54
+ # Required files.
55
+ for label, fp in [
56
+ ("config.toml", paths.config_file),
57
+ ("voice.md", paths.voice_file),
58
+ ("style.md", paths.style_file),
59
+ ("slop-blocklist.md", paths.slop_file),
60
+ ]:
61
+ if fp.is_file():
62
+ results.append(CheckResult(label, "pass"))
63
+ else:
64
+ results.append(CheckResult(label, "fail", f"missing at {fp}"))
65
+
66
+ # Config parses.
67
+ if paths.config_file.is_file():
68
+ try:
69
+ cfg = ProjectConfig.load(paths.config_file)
70
+ results.append(CheckResult("config_parses", "pass", f"project={cfg.project.name}"))
71
+ except ConfigError as e:
72
+ results.append(CheckResult("config_parses", "fail", str(e)))
73
+
74
+ # State DB.
75
+ sv = get_schema_version(paths.state_db)
76
+ if sv is None:
77
+ results.append(CheckResult("state_db", "fail", "missing or unreadable; run `devrel init`"))
78
+ elif sv == SCHEMA_VERSION:
79
+ results.append(CheckResult("state_db", "pass", f"schema v{sv}"))
80
+ else:
81
+ results.append(
82
+ CheckResult(
83
+ "state_db",
84
+ "warn",
85
+ f"schema v{sv}, current is v{SCHEMA_VERSION}; run `devrel migrate`",
86
+ )
87
+ )
88
+
89
+ # LLM key: at least one of Anthropic direct or OpenRouter must be set.
90
+ # Pull keys out of .devrel/.env (or root .env) first so a user who set
91
+ # them via `devrel auth` and hasn't restarted their shell still passes.
92
+ from devrel_origin.cli._common import _load_project_env
93
+
94
+ _load_project_env(paths)
95
+ set_keys = [n for n in LLM_KEY_OPTIONS if os.environ.get(n)]
96
+ if set_keys:
97
+ results.append(CheckResult("llm_api_key", "pass", f"set: {', '.join(set_keys)}"))
98
+ else:
99
+ results.append(
100
+ CheckResult(
101
+ "llm_api_key",
102
+ "fail",
103
+ "no LLM key set; run `devrel auth` (Anthropic or OpenRouter)",
104
+ )
105
+ )
106
+
107
+ # Optional env.
108
+ for name in OPTIONAL_ENV:
109
+ val = os.environ.get(name)
110
+ results.append(
111
+ CheckResult(name, "pass" if val else "warn", "set" if val else "not set (optional)")
112
+ )
113
+
114
+ # KB freshness.
115
+ if paths.kb_dir.is_dir():
116
+ n = sum(1 for _ in paths.kb_dir.rglob("*.md"))
117
+ results.append(CheckResult("kb_files", "pass" if n > 0 else "warn", f"{n} markdown files"))
118
+ else:
119
+ results.append(CheckResult("kb_files", "warn", "kb/ missing"))
120
+
121
+ return results
122
+
123
+
124
+ def _overall(results: list[CheckResult]) -> str:
125
+ if any(r.status == "fail" for r in results):
126
+ return "fail"
127
+ if any(r.status == "warn" for r in results):
128
+ return "warn"
129
+ return "ok"
130
+
131
+
132
+ def _emit_pretty(results: list[CheckResult], overall: str) -> None:
133
+ icons = {"pass": "[green]✓[/green]", "warn": "[yellow]![/yellow]", "fail": "[red]✗[/red]"}
134
+ for r in results:
135
+ console.print(f" {icons[r.status]} {r.name:<24} {r.detail}")
136
+ console.print()
137
+ label = {
138
+ "ok": "[bold green]All checks passed.[/bold green]",
139
+ "warn": "[bold yellow]Some warnings; nothing blocking.[/bold yellow]",
140
+ "fail": "[bold red]One or more checks failed.[/bold red]",
141
+ }[overall]
142
+ console.print(label)
143
+
144
+
145
+ def _emit_json(results: list[CheckResult], overall: str) -> None:
146
+ typer.echo(
147
+ json.dumps(
148
+ {"status": overall, "checks": [asdict(r) for r in results]},
149
+ indent=2,
150
+ )
151
+ )
152
+
153
+
154
+ def doctor_command(
155
+ json_output: bool = typer.Option(
156
+ False,
157
+ "--json",
158
+ help="Emit machine-readable JSON instead of pretty output.",
159
+ ),
160
+ ) -> None:
161
+ """Run health checks on the current project."""
162
+ try:
163
+ root = find_devrel_root()
164
+ except ProjectNotFoundError as e:
165
+ console.print(f"[red]{e}[/red]")
166
+ raise typer.Exit(code=1) from None
167
+
168
+ paths = ProjectPaths.from_root(root)
169
+ results = _run_checks(paths)
170
+ overall = _overall(results)
171
+
172
+ if json_output:
173
+ _emit_json(results, overall)
174
+ else:
175
+ _emit_pretty(results, overall)
176
+
177
+ if overall == "fail":
178
+ raise typer.Exit(code=1)
@@ -0,0 +1,29 @@
1
+ """`devrel experiment` — A/B experiment design via Nova."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from devrel_origin.cli._common import build_atlas_or_exit, find_paths_or_exit, render_result
11
+
12
+ console = Console()
13
+
14
+
15
+ def experiment_command(
16
+ hypothesis: str = typer.Argument(..., help="The hypothesis to test."),
17
+ json_output: bool = typer.Option(False, "--json"),
18
+ ) -> None:
19
+ """Design an A/B experiment with power analysis via Nova."""
20
+ paths = find_paths_or_exit(console)
21
+ atlas = build_atlas_or_exit(paths, console)
22
+
23
+ async def _do() -> None:
24
+ result = await atlas.run_single_task(
25
+ "nova", f"Design experiment for hypothesis: {hypothesis}"
26
+ )
27
+ render_result(result, console, json_output=json_output)
28
+
29
+ asyncio.run(_do())