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.
- devrel_origin/__init__.py +15 -0
- devrel_origin/cli/__init__.py +92 -0
- devrel_origin/cli/_common.py +243 -0
- devrel_origin/cli/analytics.py +28 -0
- devrel_origin/cli/argus.py +497 -0
- devrel_origin/cli/auth.py +227 -0
- devrel_origin/cli/config.py +108 -0
- devrel_origin/cli/content.py +259 -0
- devrel_origin/cli/cost.py +108 -0
- devrel_origin/cli/cro.py +298 -0
- devrel_origin/cli/deliverables.py +65 -0
- devrel_origin/cli/docs.py +91 -0
- devrel_origin/cli/doctor.py +178 -0
- devrel_origin/cli/experiment.py +29 -0
- devrel_origin/cli/growth.py +97 -0
- devrel_origin/cli/init.py +472 -0
- devrel_origin/cli/intel.py +27 -0
- devrel_origin/cli/kb.py +96 -0
- devrel_origin/cli/listen.py +31 -0
- devrel_origin/cli/marketing.py +66 -0
- devrel_origin/cli/migrate.py +45 -0
- devrel_origin/cli/run.py +46 -0
- devrel_origin/cli/sales.py +57 -0
- devrel_origin/cli/schedule.py +62 -0
- devrel_origin/cli/synthesize.py +28 -0
- devrel_origin/cli/triage.py +29 -0
- devrel_origin/cli/video.py +35 -0
- devrel_origin/core/__init__.py +58 -0
- devrel_origin/core/agent_config.py +75 -0
- devrel_origin/core/argus.py +964 -0
- devrel_origin/core/atlas.py +1450 -0
- devrel_origin/core/base.py +372 -0
- devrel_origin/core/cyra.py +563 -0
- devrel_origin/core/dex.py +708 -0
- devrel_origin/core/echo.py +614 -0
- devrel_origin/core/growth/__init__.py +27 -0
- devrel_origin/core/growth/recommendations.py +219 -0
- devrel_origin/core/growth/target_kinds.py +51 -0
- devrel_origin/core/iris.py +513 -0
- devrel_origin/core/kai.py +1367 -0
- devrel_origin/core/llm.py +542 -0
- devrel_origin/core/llm_backends.py +274 -0
- devrel_origin/core/mox.py +514 -0
- devrel_origin/core/nova.py +349 -0
- devrel_origin/core/pax.py +1205 -0
- devrel_origin/core/rex.py +532 -0
- devrel_origin/core/sage.py +486 -0
- devrel_origin/core/sentinel.py +385 -0
- devrel_origin/core/types.py +98 -0
- devrel_origin/core/video/__init__.py +22 -0
- devrel_origin/core/video/assembler.py +131 -0
- devrel_origin/core/video/browser_recorder.py +118 -0
- devrel_origin/core/video/desktop_recorder.py +254 -0
- devrel_origin/core/video/overlay_renderer.py +143 -0
- devrel_origin/core/video/script_parser.py +147 -0
- devrel_origin/core/video/tts_engine.py +82 -0
- devrel_origin/core/vox.py +268 -0
- devrel_origin/core/watchdog.py +321 -0
- devrel_origin/project/__init__.py +1 -0
- devrel_origin/project/config.py +75 -0
- devrel_origin/project/cost_sink.py +61 -0
- devrel_origin/project/init.py +104 -0
- devrel_origin/project/paths.py +75 -0
- devrel_origin/project/state.py +241 -0
- devrel_origin/project/templates/__init__.py +4 -0
- devrel_origin/project/templates/config.toml +24 -0
- devrel_origin/project/templates/devrel.gitignore +10 -0
- devrel_origin/project/templates/slop-blocklist.md +45 -0
- devrel_origin/project/templates/style.md +24 -0
- devrel_origin/project/templates/voice.md +29 -0
- devrel_origin/quality/__init__.py +66 -0
- devrel_origin/quality/editorial.py +357 -0
- devrel_origin/quality/persona.py +84 -0
- devrel_origin/quality/readability.py +148 -0
- devrel_origin/quality/slop.py +167 -0
- devrel_origin/quality/style.py +110 -0
- devrel_origin/quality/voice.py +15 -0
- devrel_origin/tools/__init__.py +9 -0
- devrel_origin/tools/analytics.py +304 -0
- devrel_origin/tools/api_client.py +393 -0
- devrel_origin/tools/apollo_client.py +305 -0
- devrel_origin/tools/code_validator.py +428 -0
- devrel_origin/tools/github_tools.py +297 -0
- devrel_origin/tools/instantly_client.py +412 -0
- devrel_origin/tools/kb_harvester.py +340 -0
- devrel_origin/tools/mcp_server.py +578 -0
- devrel_origin/tools/notifications.py +245 -0
- devrel_origin/tools/run_report.py +193 -0
- devrel_origin/tools/scheduler.py +231 -0
- devrel_origin/tools/search_tools.py +321 -0
- devrel_origin/tools/self_improve.py +168 -0
- devrel_origin/tools/sheets.py +236 -0
- devrel_origin-0.2.14.dist-info/METADATA +354 -0
- devrel_origin-0.2.14.dist-info/RECORD +98 -0
- devrel_origin-0.2.14.dist-info/WHEEL +5 -0
- devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
- devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
- devrel_origin-0.2.14.dist-info/top_level.txt +1 -0
devrel_origin/cli/cro.py
ADDED
|
@@ -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())
|