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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Cross-pillar `devrel growth` umbrella.
|
|
2
|
+
|
|
3
|
+
`summary` rolls up the latest report from each pillar (argus + cyra +
|
|
4
|
+
vega + selene) into a single Markdown table. `diff` shows pillar-level
|
|
5
|
+
movement between two periods. Pillar-specific verbs live in
|
|
6
|
+
`cli/{seo,geo,cro,argus}.py`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sqlite3
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from devrel_origin.cli._common import find_paths_or_exit
|
|
18
|
+
from devrel_origin.core.growth.target_kinds import Pillar
|
|
19
|
+
|
|
20
|
+
growth_app = typer.Typer(
|
|
21
|
+
name="growth",
|
|
22
|
+
help="Cross-pillar Growth dashboard. Pillar-specific verbs live in `seo`, `geo`, `cro`, `argus`.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_console = Console()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@growth_app.command("summary")
|
|
30
|
+
def summary(
|
|
31
|
+
period: str = typer.Option("", "--period", help="ISO period (default: latest)"),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""One-line-per-pillar status of the most recent report."""
|
|
34
|
+
paths = find_paths_or_exit(_console)
|
|
35
|
+
db_path = paths.state_db
|
|
36
|
+
if not db_path.is_file():
|
|
37
|
+
_console.print("[yellow]No state.db yet. Run `devrel run` first.[/yellow]")
|
|
38
|
+
raise typer.Exit(code=0)
|
|
39
|
+
|
|
40
|
+
table = Table(title="Growth: pillar summary")
|
|
41
|
+
table.add_column("Pillar", style="cyan")
|
|
42
|
+
table.add_column("Open recs")
|
|
43
|
+
table.add_column("Latest period")
|
|
44
|
+
|
|
45
|
+
with sqlite3.connect(db_path) as conn:
|
|
46
|
+
for pillar in Pillar:
|
|
47
|
+
cur = conn.execute(
|
|
48
|
+
"""
|
|
49
|
+
SELECT COUNT(*) AS open_recs, MAX(first_seen_period) AS latest
|
|
50
|
+
FROM analytics_recommendations
|
|
51
|
+
WHERE pillar = ? AND applied_at IS NULL
|
|
52
|
+
""",
|
|
53
|
+
(pillar.value,),
|
|
54
|
+
)
|
|
55
|
+
row = cur.fetchone()
|
|
56
|
+
open_recs = row[0] or 0
|
|
57
|
+
latest = row[1] or "-"
|
|
58
|
+
table.add_row(pillar.value, str(open_recs), latest)
|
|
59
|
+
|
|
60
|
+
_console.print(table)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@growth_app.command("diff")
|
|
64
|
+
def diff(
|
|
65
|
+
period_a: str = typer.Argument(..., help="Earlier ISO period"),
|
|
66
|
+
period_b: str = typer.Argument(..., help="Later ISO period"),
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Per-pillar count of new/closed recommendations between two periods."""
|
|
69
|
+
paths = find_paths_or_exit(_console)
|
|
70
|
+
db_path = paths.state_db
|
|
71
|
+
if not db_path.is_file():
|
|
72
|
+
_console.print("[yellow]No state.db yet. Run `devrel run` first.[/yellow]")
|
|
73
|
+
raise typer.Exit(code=0)
|
|
74
|
+
|
|
75
|
+
table = Table(title=f"Growth diff: {period_a} to {period_b}")
|
|
76
|
+
table.add_column("Pillar", style="cyan")
|
|
77
|
+
table.add_column("New", style="green")
|
|
78
|
+
table.add_column("Closed", style="dim")
|
|
79
|
+
|
|
80
|
+
with sqlite3.connect(db_path) as conn:
|
|
81
|
+
for pillar in Pillar:
|
|
82
|
+
cur = conn.execute(
|
|
83
|
+
"""
|
|
84
|
+
SELECT
|
|
85
|
+
SUM(CASE WHEN first_seen_period > ? AND first_seen_period <= ? THEN 1 ELSE 0 END) AS new_count,
|
|
86
|
+
SUM(CASE WHEN applied_at IS NOT NULL
|
|
87
|
+
AND date(applied_at) > ? AND date(applied_at) <= ?
|
|
88
|
+
THEN 1 ELSE 0 END) AS closed_count
|
|
89
|
+
FROM analytics_recommendations
|
|
90
|
+
WHERE pillar = ?
|
|
91
|
+
""",
|
|
92
|
+
(period_a, period_b, period_a, period_b, pillar.value),
|
|
93
|
+
)
|
|
94
|
+
row = cur.fetchone()
|
|
95
|
+
table.add_row(pillar.value, str(row[0] or 0), str(row[1] or 0))
|
|
96
|
+
|
|
97
|
+
_console.print(table)
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""`devrel init` command — bootstrap .devrel/ in cwd, then optionally chain
|
|
2
|
+
into the interactive onboarding wizard (auth → doctor → voice edit → first draft).
|
|
3
|
+
|
|
4
|
+
Chain behavior:
|
|
5
|
+
- Default (interactive): scaffold + run the chain
|
|
6
|
+
- `--non-interactive`: scaffold only, never prompt (CI shape, unchanged)
|
|
7
|
+
- `--skip-chain`: scaffold only, even in interactive mode
|
|
8
|
+
- `--skip-draft`: run the chain through doctor + voice edit but stop before
|
|
9
|
+
the LLM call (no spend, no network)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import stat
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
from dotenv import set_key
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
|
|
26
|
+
from devrel_origin.project.init import InitOptions, init_project
|
|
27
|
+
from devrel_origin.project.paths import ProjectPaths
|
|
28
|
+
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _detect_github_repo() -> str:
|
|
33
|
+
"""Read `git remote get-url origin` and normalize to `owner/name`.
|
|
34
|
+
|
|
35
|
+
Returns the empty string if cwd is not a git repo, has no origin, or the
|
|
36
|
+
remote URL isn't recognizable as GitHub. Pure read — never writes.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["git", "remote", "get-url", "origin"],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
timeout=5,
|
|
44
|
+
check=False,
|
|
45
|
+
)
|
|
46
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
47
|
+
return ""
|
|
48
|
+
if result.returncode != 0:
|
|
49
|
+
return ""
|
|
50
|
+
url = result.stdout.strip()
|
|
51
|
+
# Matches:
|
|
52
|
+
# git@github.com:owner/repo.git
|
|
53
|
+
# git@github.com:owner/repo
|
|
54
|
+
# https://github.com/owner/repo.git
|
|
55
|
+
# https://github.com/owner/repo
|
|
56
|
+
# ssh://git@github.com/owner/repo.git
|
|
57
|
+
match = re.search(r"github\.com[:/]([^/\s]+/[^/\s]+?)(?:\.git)?/?$", url)
|
|
58
|
+
return match.group(1) if match else ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _pick_editor() -> str:
|
|
62
|
+
"""Return the editor to launch, preferring user-friendly options over vi.
|
|
63
|
+
|
|
64
|
+
Order: $VISUAL → $EDITOR → first installed of {nano, micro, code} → vi as
|
|
65
|
+
POSIX fallback. The wizard mentions the chosen editor by name so the user
|
|
66
|
+
is never surprised by a vi prompt without knowing how to escape it.
|
|
67
|
+
"""
|
|
68
|
+
for env_var in ("VISUAL", "EDITOR"):
|
|
69
|
+
candidate = os.environ.get(env_var, "").strip()
|
|
70
|
+
if candidate:
|
|
71
|
+
return candidate
|
|
72
|
+
for friendly in ("nano", "micro", "code"):
|
|
73
|
+
if shutil.which(friendly):
|
|
74
|
+
return friendly
|
|
75
|
+
return "vi"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_CONTENT_TYPES: tuple[str, ...] = (
|
|
79
|
+
"tutorial",
|
|
80
|
+
"blog_post",
|
|
81
|
+
"landing_page",
|
|
82
|
+
"cold_email",
|
|
83
|
+
"battle_card",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _pick_content_type() -> str:
|
|
88
|
+
"""Numbered picker for content type. Free-text was a typo magnet
|
|
89
|
+
('bblog_post' got past validation in real user testing 2026-05-13)."""
|
|
90
|
+
console.print("Content type:")
|
|
91
|
+
for i, ct in enumerate(_CONTENT_TYPES, start=1):
|
|
92
|
+
console.print(f" [bold]{i}[/bold]) {ct}")
|
|
93
|
+
while True:
|
|
94
|
+
choice = typer.prompt(f"Pick [1-{len(_CONTENT_TYPES)}]", default="1").strip()
|
|
95
|
+
# Accept either the number or the name itself (handy for muscle memory).
|
|
96
|
+
if choice in _CONTENT_TYPES:
|
|
97
|
+
return choice
|
|
98
|
+
if choice.isdigit():
|
|
99
|
+
idx = int(choice) - 1
|
|
100
|
+
if 0 <= idx < len(_CONTENT_TYPES):
|
|
101
|
+
return _CONTENT_TYPES[idx]
|
|
102
|
+
console.print(
|
|
103
|
+
f"[yellow]Pick 1-{len(_CONTENT_TYPES)} or one of: {', '.join(_CONTENT_TYPES)}.[/yellow]"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def init_command(
|
|
108
|
+
name: str = typer.Option(
|
|
109
|
+
...,
|
|
110
|
+
"--name",
|
|
111
|
+
prompt="Project name (e.g., 'openclaw')",
|
|
112
|
+
help="The product this devrel-origin instance covers.",
|
|
113
|
+
),
|
|
114
|
+
url: str = typer.Option(
|
|
115
|
+
"",
|
|
116
|
+
"--url",
|
|
117
|
+
prompt="Project URL (or empty)",
|
|
118
|
+
help="Public homepage URL for the product. Optional.",
|
|
119
|
+
),
|
|
120
|
+
github_repo: str = typer.Option(
|
|
121
|
+
"",
|
|
122
|
+
"--github-repo",
|
|
123
|
+
help="Optional. Used by Sage for issue triage. Auto-detected from "
|
|
124
|
+
"`git remote get-url origin` when run inside a GitHub working copy.",
|
|
125
|
+
),
|
|
126
|
+
dry_run: bool = typer.Option(
|
|
127
|
+
False,
|
|
128
|
+
"--dry-run",
|
|
129
|
+
help="Show what would be created without writing anything.",
|
|
130
|
+
),
|
|
131
|
+
non_interactive: bool = typer.Option(
|
|
132
|
+
False,
|
|
133
|
+
"--non-interactive",
|
|
134
|
+
help="Skip prompts. Requires --name (others default to empty/null). Implies --skip-chain.",
|
|
135
|
+
),
|
|
136
|
+
skip_chain: bool = typer.Option(
|
|
137
|
+
False,
|
|
138
|
+
"--skip-chain",
|
|
139
|
+
help="Scaffold only; do not chain into the auth/doctor/draft wizard.",
|
|
140
|
+
),
|
|
141
|
+
skip_draft: bool = typer.Option(
|
|
142
|
+
False,
|
|
143
|
+
"--skip-draft",
|
|
144
|
+
help="Run the chain through health check + voice edit, but stop before the first LLM call.",
|
|
145
|
+
),
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Bootstrap a `.devrel/` scaffold in the current directory and onboard you
|
|
148
|
+
through the first run.
|
|
149
|
+
|
|
150
|
+
The chain after scaffold:
|
|
151
|
+
1. Configure an LLM key (Anthropic or OpenRouter)
|
|
152
|
+
2. Run a health check
|
|
153
|
+
3. Optionally edit voice.md to capture your tone
|
|
154
|
+
4. Generate your first content draft
|
|
155
|
+
|
|
156
|
+
Use --skip-chain to keep the old scaffold-only behavior. CI scripts that
|
|
157
|
+
pass --non-interactive get scaffold-only automatically.
|
|
158
|
+
"""
|
|
159
|
+
if non_interactive and not name:
|
|
160
|
+
console.print("[red]--non-interactive requires --name.[/red]")
|
|
161
|
+
raise typer.Exit(code=2)
|
|
162
|
+
|
|
163
|
+
# github_repo: prefer --github-repo flag; else auto-detect from
|
|
164
|
+
# `git remote get-url origin` and offer as a prompt default. CI shape
|
|
165
|
+
# (--non-interactive) skips both the prompt and the detection.
|
|
166
|
+
if not github_repo and not non_interactive:
|
|
167
|
+
detected = _detect_github_repo()
|
|
168
|
+
if detected:
|
|
169
|
+
github_repo = typer.prompt(
|
|
170
|
+
"GitHub repo as 'owner/name' (detected from origin)",
|
|
171
|
+
default=detected,
|
|
172
|
+
).strip()
|
|
173
|
+
else:
|
|
174
|
+
github_repo = typer.prompt(
|
|
175
|
+
"GitHub repo as 'owner/name' (or empty)",
|
|
176
|
+
default="",
|
|
177
|
+
show_default=False,
|
|
178
|
+
).strip()
|
|
179
|
+
|
|
180
|
+
opts = InitOptions(
|
|
181
|
+
name=name,
|
|
182
|
+
url=url,
|
|
183
|
+
github_repo=github_repo or None,
|
|
184
|
+
dry_run=dry_run,
|
|
185
|
+
)
|
|
186
|
+
result = init_project(Path.cwd(), opts)
|
|
187
|
+
|
|
188
|
+
if result.dry_run:
|
|
189
|
+
console.print("[yellow]Dry run — nothing written.[/yellow]")
|
|
190
|
+
for entry in result.would_create:
|
|
191
|
+
console.print(f" + {entry}")
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
for entry in result.created:
|
|
195
|
+
console.print(f" [green]+[/green] {entry}")
|
|
196
|
+
for entry in result.skipped:
|
|
197
|
+
console.print(f" [dim]= {entry} (existed; preserved)[/dim]")
|
|
198
|
+
console.print()
|
|
199
|
+
console.print(f"[bold green]✓[/bold green] Scaffolded .devrel/ for [cyan]{name}[/cyan].")
|
|
200
|
+
|
|
201
|
+
if non_interactive or skip_chain:
|
|
202
|
+
# Scaffold-only path. Print the manual next-steps so users who skipped
|
|
203
|
+
# the chain still know what to do.
|
|
204
|
+
console.print()
|
|
205
|
+
console.print("Next steps (run interactively for the guided wizard):")
|
|
206
|
+
console.print(
|
|
207
|
+
" 1. [cyan]devrel auth[/cyan] configure your LLM API key (Anthropic or OpenRouter)"
|
|
208
|
+
)
|
|
209
|
+
console.print(" 2. [cyan]devrel doctor[/cyan] verify everything is wired up")
|
|
210
|
+
console.print(' 3. [cyan]devrel content draft "..."[/cyan] ship your first draft')
|
|
211
|
+
console.print(
|
|
212
|
+
"[dim]Tip: OpenRouter offers free monthly credits and supports per-agent "
|
|
213
|
+
"model routing. Sign up at https://openrouter.ai/.[/dim]"
|
|
214
|
+
)
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
paths = ProjectPaths.from_root(Path.cwd())
|
|
218
|
+
_run_onboarding_chain(paths, skip_draft=skip_draft)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _run_onboarding_chain(paths: ProjectPaths, *, skip_draft: bool) -> None:
|
|
222
|
+
"""Walk the user from a fresh .devrel/ to a first content draft.
|
|
223
|
+
|
|
224
|
+
Each step is independently skippable. A 'no' on any step prints the manual
|
|
225
|
+
command the user can run later.
|
|
226
|
+
"""
|
|
227
|
+
console.print()
|
|
228
|
+
console.print("[bold]Let's get you to your first draft.[/bold]")
|
|
229
|
+
console.print("[dim]Estimated 3-5 minutes. Skip any step with 'n'.[/dim]")
|
|
230
|
+
|
|
231
|
+
if not _step_auth(paths):
|
|
232
|
+
return
|
|
233
|
+
if not _step_doctor(paths):
|
|
234
|
+
return
|
|
235
|
+
_step_edit_voice(paths)
|
|
236
|
+
if not skip_draft:
|
|
237
|
+
_step_first_draft(paths)
|
|
238
|
+
else:
|
|
239
|
+
console.print()
|
|
240
|
+
console.print(
|
|
241
|
+
'[dim]Skipped first draft (--skip-draft). Run `devrel content draft "..."` '
|
|
242
|
+
"when ready.[/dim]"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _step_auth(paths: ProjectPaths) -> bool:
|
|
247
|
+
"""Step 2: configure an LLM key, or detect an existing one. Returns False
|
|
248
|
+
if the user opts out and the chain should stop."""
|
|
249
|
+
from devrel_origin.cli.auth import (
|
|
250
|
+
KEY_VAR,
|
|
251
|
+
PROVIDER_ANTHROPIC,
|
|
252
|
+
_ensure_env_file,
|
|
253
|
+
_existing_key,
|
|
254
|
+
_resolve_key,
|
|
255
|
+
_resolve_provider,
|
|
256
|
+
_validate,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
console.print()
|
|
260
|
+
console.print("[bold]Step 1 of 4: LLM provider[/bold]")
|
|
261
|
+
|
|
262
|
+
# Detect any pre-existing key (from a prior init or manually-edited .env)
|
|
263
|
+
# and short-circuit auth if found, so re-running init doesn't ask again.
|
|
264
|
+
for existing_var in KEY_VAR.values():
|
|
265
|
+
if _existing_key(paths.env_file, existing_var):
|
|
266
|
+
console.print(
|
|
267
|
+
f"[green]✓[/green] {existing_var} already configured in "
|
|
268
|
+
f".devrel/.env. Use [cyan]devrel auth --rotate[/cyan] to replace it."
|
|
269
|
+
)
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
do_auth = typer.confirm("Configure your LLM key now?", default=True)
|
|
273
|
+
if not do_auth:
|
|
274
|
+
console.print(
|
|
275
|
+
"[dim]Skipping. Run [cyan]devrel auth[/cyan] later to configure the key.[/dim]"
|
|
276
|
+
)
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
chosen = _resolve_provider(None, non_interactive=False)
|
|
280
|
+
var = KEY_VAR[chosen]
|
|
281
|
+
new_key = _resolve_key(
|
|
282
|
+
chosen,
|
|
283
|
+
arg=None,
|
|
284
|
+
non_interactive=False,
|
|
285
|
+
rotating=False,
|
|
286
|
+
existing="",
|
|
287
|
+
)
|
|
288
|
+
if not new_key:
|
|
289
|
+
console.print("[red]Empty key; skipping.[/red]")
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
console.print(f"Validating {var} against {chosen}...")
|
|
293
|
+
ok, err = asyncio.run(_validate(chosen, new_key))
|
|
294
|
+
if not ok:
|
|
295
|
+
console.print(f"[red]Validation failed:[/red] {err}")
|
|
296
|
+
retry = typer.confirm("Save the key anyway (skip validation)?", default=False)
|
|
297
|
+
if not retry:
|
|
298
|
+
console.print("[dim]Skipping. Fix the key and run [cyan]devrel auth[/cyan].[/dim]")
|
|
299
|
+
return False
|
|
300
|
+
else:
|
|
301
|
+
console.print("[green]✓[/green] key validated")
|
|
302
|
+
|
|
303
|
+
_ensure_env_file(paths.env_file)
|
|
304
|
+
set_key(str(paths.env_file), var, new_key, quote_mode="never")
|
|
305
|
+
try:
|
|
306
|
+
paths.env_file.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
307
|
+
except OSError:
|
|
308
|
+
pass
|
|
309
|
+
# Surface the key in the current process env so the draft step in this
|
|
310
|
+
# same run can use it without restarting the shell.
|
|
311
|
+
os.environ[var] = new_key
|
|
312
|
+
|
|
313
|
+
masked = new_key[:4] + "..." + new_key[-4:] if len(new_key) > 8 else "***"
|
|
314
|
+
console.print(f"[green]✓[/green] saved {var}={masked} to .devrel/.env (mode 0600)")
|
|
315
|
+
if chosen == PROVIDER_ANTHROPIC:
|
|
316
|
+
console.print(
|
|
317
|
+
"[dim]Tip: switch providers with `devrel auth --provider openrouter` "
|
|
318
|
+
"(free credits at https://openrouter.ai/).[/dim]"
|
|
319
|
+
)
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _step_doctor(paths: ProjectPaths) -> bool:
|
|
324
|
+
"""Step 3: run health checks inline. Returns False if user aborts on
|
|
325
|
+
failures."""
|
|
326
|
+
from devrel_origin.cli.doctor import _emit_pretty, _overall, _run_checks
|
|
327
|
+
|
|
328
|
+
console.print()
|
|
329
|
+
console.print("[bold]Step 2 of 4: Health check[/bold]")
|
|
330
|
+
results = _run_checks(paths)
|
|
331
|
+
overall = _overall(results)
|
|
332
|
+
_emit_pretty(results, overall)
|
|
333
|
+
|
|
334
|
+
if overall == "fail":
|
|
335
|
+
console.print()
|
|
336
|
+
proceed = typer.confirm(
|
|
337
|
+
"Some checks failed. Continue with voice edit + first draft anyway?",
|
|
338
|
+
default=False,
|
|
339
|
+
)
|
|
340
|
+
if not proceed:
|
|
341
|
+
console.print(
|
|
342
|
+
"[dim]Stopping. Fix the failing checks and re-run [cyan]devrel doctor[/cyan].[/dim]"
|
|
343
|
+
)
|
|
344
|
+
return False
|
|
345
|
+
return True
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _step_edit_voice(paths: ProjectPaths) -> None:
|
|
349
|
+
"""Step 4: open voice.md in $EDITOR. Optional but pushed by default
|
|
350
|
+
because un-edited voice.md produces generic output."""
|
|
351
|
+
console.print()
|
|
352
|
+
console.print("[bold]Step 3 of 4: Make it sound like you[/bold]")
|
|
353
|
+
console.print(
|
|
354
|
+
"Drop 3-5 short sample passages from your best published content into "
|
|
355
|
+
"[cyan]voice.md[/cyan]. The persona pass + Sentinel use them to detect drift."
|
|
356
|
+
)
|
|
357
|
+
editor = _pick_editor()
|
|
358
|
+
do_edit = typer.confirm(
|
|
359
|
+
f"Open .devrel/voice.md in {editor} now?",
|
|
360
|
+
default=True,
|
|
361
|
+
)
|
|
362
|
+
if not do_edit:
|
|
363
|
+
console.print(
|
|
364
|
+
"[dim]Skipping. Edit [cyan].devrel/voice.md[/cyan] later when you're ready. "
|
|
365
|
+
"Same goes for style.md and slop-blocklist.md.[/dim]"
|
|
366
|
+
)
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
subprocess.run([editor, str(paths.voice_file)], check=False)
|
|
371
|
+
except FileNotFoundError:
|
|
372
|
+
console.print(
|
|
373
|
+
f"[yellow]Editor [/yellow][cyan]{editor}[/cyan][yellow] not found. "
|
|
374
|
+
f"Set $EDITOR or edit .devrel/voice.md manually later.[/yellow]"
|
|
375
|
+
)
|
|
376
|
+
return
|
|
377
|
+
console.print(
|
|
378
|
+
"[green]✓[/green] voice.md edited. Repeat for [cyan]style.md[/cyan] and "
|
|
379
|
+
"[cyan]slop-blocklist.md[/cyan] when you have time."
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _step_first_draft(paths: ProjectPaths) -> None:
|
|
384
|
+
"""Step 5: generate a real content draft via Kai. Costs an API call."""
|
|
385
|
+
console.print()
|
|
386
|
+
console.print("[bold]Step 4 of 4: First content draft[/bold]")
|
|
387
|
+
console.print(
|
|
388
|
+
"[dim]This calls your LLM provider once (~30s, a few cents). Skip with 'n' "
|
|
389
|
+
"to finish onboarding without an API call.[/dim]"
|
|
390
|
+
)
|
|
391
|
+
do_draft = typer.confirm("Generate your first content draft now?", default=True)
|
|
392
|
+
if not do_draft:
|
|
393
|
+
console.print(
|
|
394
|
+
'[dim]Skipping. Run [cyan]devrel content draft "..."[/cyan] when ready.[/dim]'
|
|
395
|
+
)
|
|
396
|
+
_print_done_summary(paths)
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
topic = typer.prompt("Topic or prompt").strip()
|
|
400
|
+
if not topic:
|
|
401
|
+
console.print("[yellow]Empty topic; skipping draft.[/yellow]")
|
|
402
|
+
_print_done_summary(paths)
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
content_type = _pick_content_type()
|
|
406
|
+
|
|
407
|
+
# Build Kai inline using the same wiring as `devrel content draft`. We
|
|
408
|
+
# call into the existing draft_command via Typer's invocation mechanism
|
|
409
|
+
# would print twice; easier to import the helpers and replay the body.
|
|
410
|
+
from devrel_origin.cli.content import _build_kai, _build_llm_client, _slug, _write_outputs
|
|
411
|
+
|
|
412
|
+
# _build_llm_client now resolves the LLM key from .devrel/.env (Anthropic
|
|
413
|
+
# OR OpenRouter) and raises typer.Exit(1) with a helpful message if neither
|
|
414
|
+
# is configured. Letting Exit propagate exits the wizard cleanly with the
|
|
415
|
+
# missing-key help; the user can then run `devrel auth` and re-run.
|
|
416
|
+
client = _build_llm_client(paths)
|
|
417
|
+
kai = _build_kai(paths, client)
|
|
418
|
+
|
|
419
|
+
console.print(f"[dim]Generating draft on '{topic[:60]}'...[/dim]")
|
|
420
|
+
|
|
421
|
+
async def _do() -> None:
|
|
422
|
+
result = await kai.execute(task=topic, content_type=content_type)
|
|
423
|
+
status = result.get("status")
|
|
424
|
+
body = result.get("content") or ""
|
|
425
|
+
if status != "generated" or not body:
|
|
426
|
+
console.print(f"[red]Kai did not produce content (status={status}).[/red]")
|
|
427
|
+
for gap in result.get("evidence_gaps", []):
|
|
428
|
+
console.print(f" - {gap}")
|
|
429
|
+
if result.get("error"):
|
|
430
|
+
console.print(f" error: {result['error']}")
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
trace = {
|
|
434
|
+
"agent": "kai",
|
|
435
|
+
"task": result.get("task"),
|
|
436
|
+
"content_type": content_type,
|
|
437
|
+
"grounding_sources": result.get("grounding_sources", []),
|
|
438
|
+
"pain_points_addressed": result.get("pain_points_addressed", []),
|
|
439
|
+
"real_issues_referenced": result.get("real_issues_referenced", []),
|
|
440
|
+
"revision": result.get("revision", {}),
|
|
441
|
+
"code_validation": result.get("code_validation", {}),
|
|
442
|
+
}
|
|
443
|
+
body_path, trace_path = _write_outputs(paths, _slug(topic), body, trace)
|
|
444
|
+
console.print(f"[green]✓[/green] Wrote {body_path.name} ({len(body)} chars)")
|
|
445
|
+
console.print(f"[green]✓[/green] Wrote {trace_path.name}")
|
|
446
|
+
|
|
447
|
+
sources = result.get("grounding_sources") or []
|
|
448
|
+
if not sources:
|
|
449
|
+
console.print(
|
|
450
|
+
"[yellow]⚠[/yellow] No KB sources matched the prompt; output may be "
|
|
451
|
+
"ungrounded. Run [cyan]devrel kb add <url>[/cyan] to populate the KB."
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
asyncio.run(_do())
|
|
455
|
+
_print_done_summary(paths)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _print_done_summary(paths: ProjectPaths) -> None:
|
|
459
|
+
"""Closing message regardless of which step the chain ended at."""
|
|
460
|
+
console.print()
|
|
461
|
+
console.print("[bold green]✓ Onboarding complete.[/bold green]")
|
|
462
|
+
console.print()
|
|
463
|
+
console.print("Where to go next:")
|
|
464
|
+
console.print(" • Read your draft: [cyan]ls .devrel/deliverables/[/cyan]")
|
|
465
|
+
console.print(" • Populate the KB: [cyan]devrel kb add https://yourdocs.com[/cyan]")
|
|
466
|
+
console.print(
|
|
467
|
+
" • Tune editorial: edit [cyan].devrel/style.md[/cyan] + "
|
|
468
|
+
"[cyan].devrel/slop-blocklist.md[/cyan]"
|
|
469
|
+
)
|
|
470
|
+
console.print(" • Full weekly run: [cyan]devrel run[/cyan]")
|
|
471
|
+
console.print()
|
|
472
|
+
console.print("[dim]Stuck? See docs/troubleshooting.md.[/dim]")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""`devrel intel` — competitive intelligence via Rex."""
|
|
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 intel_command(
|
|
16
|
+
competitor: str = typer.Argument(..., help="Competitor name."),
|
|
17
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Gather competitive intel on a named competitor."""
|
|
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("rex", f"Compile competitive intel on {competitor}")
|
|
25
|
+
render_result(result, console, json_output=json_output)
|
|
26
|
+
|
|
27
|
+
asyncio.run(_do())
|
devrel_origin/cli/kb.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""`devrel kb {add, list, refresh}` — wraps tools.kb_harvester.KBHarvester.
|
|
2
|
+
|
|
3
|
+
Wraps the existing `KBHarvester` tool. The harvester ctor takes
|
|
4
|
+
`kb_path: Path` plus an optional `firecrawl_api_key`; we pull the latter
|
|
5
|
+
from the environment so the CLI degrades gracefully when no key is set.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
from devrel_origin.cli._common import find_paths_or_exit
|
|
18
|
+
from devrel_origin.tools.kb_harvester import KBHarvester
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
kb_app = typer.Typer(
|
|
23
|
+
name="kb",
|
|
24
|
+
help="Knowledge base: add URLs, list documents, refresh from configured sources.",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
add_completion=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_harvester(kb_path: Path) -> KBHarvester:
|
|
31
|
+
api_key = os.environ.get("FIRECRAWL_API_KEY", "")
|
|
32
|
+
return KBHarvester(kb_path=kb_path, firecrawl_api_key=api_key)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@kb_app.command("add")
|
|
36
|
+
def add(
|
|
37
|
+
url: str = typer.Argument(..., help="URL to harvest into the knowledge base."),
|
|
38
|
+
category: str = typer.Option(
|
|
39
|
+
"misc", "--category", help="KB category folder for the harvested doc."
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Harvest a single URL into the project KB."""
|
|
43
|
+
paths = find_paths_or_exit(console)
|
|
44
|
+
harvester = _build_harvester(paths.kb_dir)
|
|
45
|
+
|
|
46
|
+
async def _do() -> None:
|
|
47
|
+
try:
|
|
48
|
+
doc = await harvester.harvest_url(url, category=category)
|
|
49
|
+
finally:
|
|
50
|
+
await harvester.close()
|
|
51
|
+
if doc is None:
|
|
52
|
+
console.print(f"[red]Failed to harvest {url}[/red]")
|
|
53
|
+
raise typer.Exit(code=1)
|
|
54
|
+
console.print(f"[green]✓[/green] Saved {doc.filename} → {doc.category}/")
|
|
55
|
+
|
|
56
|
+
asyncio.run(_do())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@kb_app.command("list")
|
|
60
|
+
def list_docs() -> None:
|
|
61
|
+
"""List markdown docs currently in the KB."""
|
|
62
|
+
paths = find_paths_or_exit(console)
|
|
63
|
+
if not paths.kb_dir.exists():
|
|
64
|
+
console.print("[yellow]No KB directory yet.[/yellow]")
|
|
65
|
+
return
|
|
66
|
+
docs = sorted(paths.kb_dir.rglob("*.md"))
|
|
67
|
+
if not docs:
|
|
68
|
+
console.print("[yellow]KB is empty.[/yellow]")
|
|
69
|
+
return
|
|
70
|
+
for d in docs:
|
|
71
|
+
rel = d.relative_to(paths.kb_dir)
|
|
72
|
+
console.print(f" [dim]{rel}[/dim]")
|
|
73
|
+
console.print(f"\n[green]{len(docs)} doc(s)[/green]")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@kb_app.command("refresh")
|
|
77
|
+
def refresh() -> None:
|
|
78
|
+
"""Re-harvest all configured KB sources."""
|
|
79
|
+
paths = find_paths_or_exit(console)
|
|
80
|
+
harvester = _build_harvester(paths.kb_dir)
|
|
81
|
+
|
|
82
|
+
async def _do() -> None:
|
|
83
|
+
try:
|
|
84
|
+
report = await harvester.harvest_all()
|
|
85
|
+
finally:
|
|
86
|
+
await harvester.close()
|
|
87
|
+
ok = report.get("harvested", 0)
|
|
88
|
+
failed = report.get("failed", 0)
|
|
89
|
+
console.print(f"[green]✓[/green] harvested={ok} failed={failed}")
|
|
90
|
+
for src in report.get("sources", []):
|
|
91
|
+
status = src.get("status", "?")
|
|
92
|
+
name = src.get("name", "?")
|
|
93
|
+
color = "green" if status == "ok" else "red"
|
|
94
|
+
console.print(f" [{color}]{status}[/{color}] {name}")
|
|
95
|
+
|
|
96
|
+
asyncio.run(_do())
|