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,227 @@
|
|
|
1
|
+
"""`devrel auth` - configure or rotate the LLM API key for this project.
|
|
2
|
+
|
|
3
|
+
Writes the chosen key to `.devrel/.env` (chmod 600) so subsequent CLI
|
|
4
|
+
commands pick it up via the auto-loader in `_common._load_project_env`.
|
|
5
|
+
Validates the key with a tiny ping call by default; pass `--no-validate`
|
|
6
|
+
to skip when offline or working with credit-metered keys.
|
|
7
|
+
|
|
8
|
+
Provider resolution:
|
|
9
|
+
- `--provider anthropic` or `--provider openrouter` is explicit
|
|
10
|
+
- Without `--provider`, prompts in interactive mode; defaults to anthropic
|
|
11
|
+
in non-interactive mode (preserves prior CLI default)
|
|
12
|
+
|
|
13
|
+
Key handling:
|
|
14
|
+
- `--key VALUE` accepts the key on the command line (history risk; OK for CI)
|
|
15
|
+
- Without `--key`, prompts with hidden input
|
|
16
|
+
- `--rotate` lets the user replace an existing key for the same provider;
|
|
17
|
+
without it, an existing key for the chosen provider blocks (use --rotate
|
|
18
|
+
to overwrite or pick a different provider)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import os
|
|
25
|
+
import stat
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import typer
|
|
29
|
+
from dotenv import dotenv_values, set_key
|
|
30
|
+
from rich.console import Console
|
|
31
|
+
|
|
32
|
+
from devrel_origin.cli._common import find_paths_or_exit
|
|
33
|
+
from devrel_origin.core.llm import LLMClient
|
|
34
|
+
from devrel_origin.project.paths import ProjectPaths
|
|
35
|
+
|
|
36
|
+
console = Console()
|
|
37
|
+
|
|
38
|
+
PROVIDER_ANTHROPIC = "anthropic"
|
|
39
|
+
PROVIDER_OPENROUTER = "openrouter"
|
|
40
|
+
PROVIDERS = (PROVIDER_ANTHROPIC, PROVIDER_OPENROUTER)
|
|
41
|
+
KEY_VAR = {
|
|
42
|
+
PROVIDER_ANTHROPIC: "ANTHROPIC_API_KEY",
|
|
43
|
+
PROVIDER_OPENROUTER: "OPENROUTER_API_KEY",
|
|
44
|
+
}
|
|
45
|
+
SIGNUP_URL = {
|
|
46
|
+
PROVIDER_ANTHROPIC: "https://console.anthropic.com/settings/keys",
|
|
47
|
+
PROVIDER_OPENROUTER: "https://openrouter.ai/keys",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ensure_env_file(env_file: Path) -> None:
|
|
52
|
+
"""Touch .devrel/.env if it doesn't exist and lock it to 0600."""
|
|
53
|
+
env_file.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
if not env_file.is_file():
|
|
55
|
+
env_file.touch()
|
|
56
|
+
# chmod 600 unconditionally so the file is locked down even if it
|
|
57
|
+
# pre-existed at a looser permission. POSIX-only; no-op on Windows
|
|
58
|
+
# but the perm bits are advisory there anyway.
|
|
59
|
+
try:
|
|
60
|
+
env_file.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
61
|
+
except OSError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _existing_key(env_file: Path, var: str) -> str:
|
|
66
|
+
if not env_file.is_file():
|
|
67
|
+
return ""
|
|
68
|
+
return (dotenv_values(env_file).get(var) or "").strip()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _validate(provider: str, key: str) -> tuple[bool, str]:
|
|
72
|
+
"""One-token ping. Returns (ok, error_message)."""
|
|
73
|
+
try:
|
|
74
|
+
if provider == PROVIDER_ANTHROPIC:
|
|
75
|
+
client = LLMClient(provider=PROVIDER_ANTHROPIC, api_key=key)
|
|
76
|
+
else:
|
|
77
|
+
client = LLMClient(provider=PROVIDER_OPENROUTER, openrouter_api_key=key)
|
|
78
|
+
try:
|
|
79
|
+
await client.generate(
|
|
80
|
+
system_prompt="Reply with the single word: ok",
|
|
81
|
+
user_prompt="ping",
|
|
82
|
+
max_tokens=5,
|
|
83
|
+
temperature=0.0,
|
|
84
|
+
)
|
|
85
|
+
finally:
|
|
86
|
+
await client.aclose()
|
|
87
|
+
return True, ""
|
|
88
|
+
except Exception as exc: # noqa: BLE001 - surface any auth error to the user
|
|
89
|
+
return False, str(exc)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _resolve_provider(
|
|
93
|
+
arg: str | None,
|
|
94
|
+
*,
|
|
95
|
+
non_interactive: bool,
|
|
96
|
+
) -> str:
|
|
97
|
+
if arg:
|
|
98
|
+
if arg not in PROVIDERS:
|
|
99
|
+
console.print(
|
|
100
|
+
f"[red]Unknown provider '{arg}'. Choose one of: {', '.join(PROVIDERS)}.[/red]"
|
|
101
|
+
)
|
|
102
|
+
raise typer.Exit(code=1)
|
|
103
|
+
return arg
|
|
104
|
+
if non_interactive:
|
|
105
|
+
return PROVIDER_ANTHROPIC
|
|
106
|
+
console.print("Pick an LLM provider:")
|
|
107
|
+
console.print(" [bold]1[/bold]) anthropic (https://console.anthropic.com/settings/keys)")
|
|
108
|
+
console.print(
|
|
109
|
+
" [bold]2[/bold]) openrouter (https://openrouter.ai/keys, free credits available)"
|
|
110
|
+
)
|
|
111
|
+
choice = typer.prompt("Provider [1/2]", default="1").strip()
|
|
112
|
+
return PROVIDER_OPENROUTER if choice in ("2", "openrouter", "or") else PROVIDER_ANTHROPIC
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _resolve_key(
|
|
116
|
+
provider: str,
|
|
117
|
+
*,
|
|
118
|
+
arg: str | None,
|
|
119
|
+
non_interactive: bool,
|
|
120
|
+
rotating: bool,
|
|
121
|
+
existing: str,
|
|
122
|
+
) -> str:
|
|
123
|
+
if arg:
|
|
124
|
+
return arg.strip()
|
|
125
|
+
if non_interactive:
|
|
126
|
+
console.print(
|
|
127
|
+
f"[red]--key is required in --non-interactive mode. "
|
|
128
|
+
f"Pass --key <value> or set {KEY_VAR[provider]}.[/red]"
|
|
129
|
+
)
|
|
130
|
+
raise typer.Exit(code=1)
|
|
131
|
+
if existing and not rotating:
|
|
132
|
+
console.print(
|
|
133
|
+
f"[yellow]A {KEY_VAR[provider]} is already set in .devrel/.env. "
|
|
134
|
+
f"Pass --rotate to replace it, or pick a different provider.[/yellow]"
|
|
135
|
+
)
|
|
136
|
+
raise typer.Exit(code=1)
|
|
137
|
+
label = "Paste new" if existing else "Paste"
|
|
138
|
+
return typer.prompt(
|
|
139
|
+
f"{label} {KEY_VAR[provider]} (input hidden)",
|
|
140
|
+
hide_input=True,
|
|
141
|
+
).strip()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def auth_command(
|
|
145
|
+
provider: str = typer.Option(
|
|
146
|
+
"",
|
|
147
|
+
"--provider",
|
|
148
|
+
help="LLM provider: anthropic or openrouter. Prompts if omitted.",
|
|
149
|
+
),
|
|
150
|
+
key: str = typer.Option(
|
|
151
|
+
"",
|
|
152
|
+
"--key",
|
|
153
|
+
help="API key (skip the prompt; not recommended interactively).",
|
|
154
|
+
),
|
|
155
|
+
rotate: bool = typer.Option(
|
|
156
|
+
False, "--rotate", help="Replace an existing key for the same provider."
|
|
157
|
+
),
|
|
158
|
+
no_validate: bool = typer.Option(
|
|
159
|
+
False, "--no-validate", help="Skip the ping call that verifies the key."
|
|
160
|
+
),
|
|
161
|
+
non_interactive: bool = typer.Option(
|
|
162
|
+
False,
|
|
163
|
+
"--non-interactive",
|
|
164
|
+
help="Fail instead of prompting when --provider or --key is missing.",
|
|
165
|
+
),
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Configure (or rotate) the LLM API key for this project."""
|
|
168
|
+
paths: ProjectPaths = find_paths_or_exit(console)
|
|
169
|
+
chosen = _resolve_provider(provider or None, non_interactive=non_interactive)
|
|
170
|
+
var = KEY_VAR[chosen]
|
|
171
|
+
existing = _existing_key(paths.env_file, var)
|
|
172
|
+
new_key = _resolve_key(
|
|
173
|
+
chosen,
|
|
174
|
+
arg=key or None,
|
|
175
|
+
non_interactive=non_interactive,
|
|
176
|
+
rotating=rotate,
|
|
177
|
+
existing=existing,
|
|
178
|
+
)
|
|
179
|
+
if not new_key:
|
|
180
|
+
console.print("[red]Empty key; nothing to do.[/red]")
|
|
181
|
+
raise typer.Exit(code=1)
|
|
182
|
+
|
|
183
|
+
if not no_validate:
|
|
184
|
+
console.print(f"Validating {var} against {chosen}...")
|
|
185
|
+
ok, err = asyncio.run(_validate(chosen, new_key))
|
|
186
|
+
if not ok:
|
|
187
|
+
console.print(f"[red]Validation failed:[/red] {err}")
|
|
188
|
+
console.print(
|
|
189
|
+
"[dim]Pass --no-validate to write the key anyway "
|
|
190
|
+
"(useful for offline or rate-limited setups).[/dim]"
|
|
191
|
+
)
|
|
192
|
+
raise typer.Exit(code=1)
|
|
193
|
+
console.print("[green]✓[/green] key validated")
|
|
194
|
+
|
|
195
|
+
_ensure_env_file(paths.env_file)
|
|
196
|
+
set_key(str(paths.env_file), var, new_key, quote_mode="never")
|
|
197
|
+
# Re-apply 0600 in case set_key recreated the file with default perms.
|
|
198
|
+
try:
|
|
199
|
+
paths.env_file.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
200
|
+
except OSError:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
masked = new_key[:4] + "..." + new_key[-4:] if len(new_key) > 8 else "***"
|
|
204
|
+
rel = paths.env_file
|
|
205
|
+
try:
|
|
206
|
+
rel = paths.env_file.relative_to(paths.root)
|
|
207
|
+
except ValueError:
|
|
208
|
+
pass
|
|
209
|
+
verb = "rotated" if existing else "saved"
|
|
210
|
+
console.print(f"[green]✓[/green] {verb} {var}={masked} to {rel} (mode 0600)")
|
|
211
|
+
console.print()
|
|
212
|
+
console.print("Next steps:")
|
|
213
|
+
console.print(
|
|
214
|
+
" 1. [cyan]devrel doctor[/cyan] confirm everything is wired up"
|
|
215
|
+
)
|
|
216
|
+
console.print(
|
|
217
|
+
' 2. [cyan]devrel content draft "..."[/cyan] ship your first grounded draft'
|
|
218
|
+
)
|
|
219
|
+
if chosen == PROVIDER_ANTHROPIC:
|
|
220
|
+
console.print(
|
|
221
|
+
"[dim]Tip: switch providers with `devrel auth --provider openrouter` "
|
|
222
|
+
"(free credits at https://openrouter.ai/).[/dim]"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Make the key visible in the current process env too, in case the user
|
|
226
|
+
# immediately runs another devrel verb in the same shell pipeline.
|
|
227
|
+
os.environ[var] = new_key
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""`devrel config {get, set}` — read/write .devrel/config.toml values.
|
|
2
|
+
|
|
3
|
+
Dotted keys (e.g. `budget.monthly_usd`) navigate nested tables. `set`
|
|
4
|
+
performs naive type coercion: int / float / "true"|"false" / fallback to
|
|
5
|
+
string. The TOML file is round-tripped via tomllib (read) + tomli_w
|
|
6
|
+
(write); comments are not preserved (acceptable for a setter).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import tomllib
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import tomli_w
|
|
17
|
+
import typer
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
|
|
20
|
+
from devrel_origin.cli._common import find_paths_or_exit
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
config_app = typer.Typer(
|
|
25
|
+
name="config",
|
|
26
|
+
help="Read and write .devrel/config.toml values by dotted key.",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
add_completion=False,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _coerce(value: str) -> Any:
|
|
33
|
+
"""Coerce a string from the CLI into int / float / bool / str."""
|
|
34
|
+
s = value.strip()
|
|
35
|
+
if s.lower() == "true":
|
|
36
|
+
return True
|
|
37
|
+
if s.lower() == "false":
|
|
38
|
+
return False
|
|
39
|
+
# int (no leading sign issues, no underscore, no decimal point)
|
|
40
|
+
try:
|
|
41
|
+
if s and (s[0] in "+-" or s[0].isdigit()):
|
|
42
|
+
if "." not in s and "e" not in s.lower():
|
|
43
|
+
return int(s)
|
|
44
|
+
return float(s)
|
|
45
|
+
except ValueError:
|
|
46
|
+
pass
|
|
47
|
+
return s
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_nested(d: dict, key: str) -> Any:
|
|
51
|
+
cur: Any = d
|
|
52
|
+
for part in key.split("."):
|
|
53
|
+
if not isinstance(cur, dict) or part not in cur:
|
|
54
|
+
raise KeyError(key)
|
|
55
|
+
cur = cur[part]
|
|
56
|
+
return cur
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _set_nested(d: dict, key: str, value: Any) -> None:
|
|
60
|
+
parts = key.split(".")
|
|
61
|
+
cur = d
|
|
62
|
+
for part in parts[:-1]:
|
|
63
|
+
if part not in cur or not isinstance(cur[part], dict):
|
|
64
|
+
cur[part] = {}
|
|
65
|
+
cur = cur[part]
|
|
66
|
+
cur[parts[-1]] = value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _load(path: Path) -> dict:
|
|
70
|
+
with path.open("rb") as f:
|
|
71
|
+
return tomllib.load(f)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _dump(path: Path, data: dict) -> None:
|
|
75
|
+
with path.open("wb") as f:
|
|
76
|
+
tomli_w.dump(data, f)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@config_app.command("get")
|
|
80
|
+
def get_value(
|
|
81
|
+
key: str = typer.Argument(..., help="Dotted key (e.g. 'project.name', 'budget.monthly_usd')."),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Read a config value."""
|
|
84
|
+
paths = find_paths_or_exit(console)
|
|
85
|
+
data = _load(paths.config_file)
|
|
86
|
+
try:
|
|
87
|
+
val = _get_nested(data, key)
|
|
88
|
+
except KeyError:
|
|
89
|
+
console.print(f"[red]Key not found: {key}[/red]")
|
|
90
|
+
raise typer.Exit(code=1) from None
|
|
91
|
+
if isinstance(val, (dict, list)):
|
|
92
|
+
typer.echo(json.dumps(val, indent=2))
|
|
93
|
+
else:
|
|
94
|
+
typer.echo(str(val))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@config_app.command("set")
|
|
98
|
+
def set_value(
|
|
99
|
+
key: str = typer.Argument(..., help="Dotted key to set."),
|
|
100
|
+
value: str = typer.Argument(..., help="Value (int/float/true/false/string auto-detected)."),
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Write a config value (round-trips via tomli_w; comments not preserved)."""
|
|
103
|
+
paths = find_paths_or_exit(console)
|
|
104
|
+
data = _load(paths.config_file)
|
|
105
|
+
coerced = _coerce(value)
|
|
106
|
+
_set_nested(data, key, coerced)
|
|
107
|
+
_dump(paths.config_file, data)
|
|
108
|
+
console.print(f"[green]✓[/green] {key} = {coerced!r} ({type(coerced).__name__})")
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""`devrel content draft|audit` — primary entry points to the editorial pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from devrel_origin.cli._common import _build_llm_client as _build_project_llm_client
|
|
17
|
+
from devrel_origin.core.kai import Kai
|
|
18
|
+
from devrel_origin.core.llm import LLMClient
|
|
19
|
+
from devrel_origin.project.paths import ProjectNotFoundError, ProjectPaths, find_devrel_root
|
|
20
|
+
from devrel_origin.quality.editorial import AbortLoud, run_pipeline
|
|
21
|
+
from devrel_origin.quality.slop import find_slop, parse_blocklist
|
|
22
|
+
from devrel_origin.tools.api_client import PostHogClient
|
|
23
|
+
from devrel_origin.tools.search_tools import SearchTools
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
content_app = typer.Typer(
|
|
28
|
+
name="content",
|
|
29
|
+
help="Generate and audit content through the editorial quality pipeline.",
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
add_completion=False,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _build_llm_client(paths: ProjectPaths) -> LLMClient:
|
|
36
|
+
return _build_project_llm_client(paths, console)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_kai(paths: ProjectPaths, llm_client: LLMClient) -> Kai:
|
|
40
|
+
"""Wire Kai with the same KB + optional search-tools the weekly cycle uses,
|
|
41
|
+
so `devrel content draft` produces grounded, code-validated output instead
|
|
42
|
+
of an editorial-pipeline pass over a generic LLM draft."""
|
|
43
|
+
posthog = PostHogClient(
|
|
44
|
+
api_key=os.environ.get("POSTHOG_API_KEY", ""),
|
|
45
|
+
project_id=os.environ.get("POSTHOG_PROJECT_ID", ""),
|
|
46
|
+
)
|
|
47
|
+
search_tools: SearchTools | None = None
|
|
48
|
+
if (
|
|
49
|
+
os.environ.get("FIRECRAWL_API_KEY", "").strip()
|
|
50
|
+
or os.environ.get("BRAVE_API_KEY", "").strip()
|
|
51
|
+
):
|
|
52
|
+
search_tools = SearchTools(
|
|
53
|
+
firecrawl_api_key=os.environ.get("FIRECRAWL_API_KEY", ""),
|
|
54
|
+
brave_api_key=os.environ.get("BRAVE_API_KEY", ""),
|
|
55
|
+
)
|
|
56
|
+
return Kai(
|
|
57
|
+
api_client=posthog,
|
|
58
|
+
knowledge_base_path=paths.kb_dir,
|
|
59
|
+
llm_client=llm_client,
|
|
60
|
+
search_tools=search_tools,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _slug(text: str) -> str:
|
|
65
|
+
s = re.sub(r"[^a-zA-Z0-9]+", "-", text.lower()).strip("-")
|
|
66
|
+
return s[:48] or "draft"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _write_outputs(paths: ProjectPaths, slug: str, body: str, trace: dict) -> tuple[Path, Path]:
|
|
70
|
+
paths.deliverables_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
72
|
+
body_path = paths.deliverables_dir / f"{ts}-{slug}.md"
|
|
73
|
+
trace_path = paths.deliverables_dir / f"{ts}-{slug}-trace.json"
|
|
74
|
+
body_path.write_text(body)
|
|
75
|
+
trace_path.write_text(json.dumps(trace, indent=2))
|
|
76
|
+
return body_path, trace_path
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@content_app.command("draft")
|
|
80
|
+
def draft_command(
|
|
81
|
+
prompt: str = typer.Argument(..., help="Topic or instruction for the new content."),
|
|
82
|
+
content_type: str = typer.Option(
|
|
83
|
+
"tutorial",
|
|
84
|
+
"--type",
|
|
85
|
+
help="Content type for targeting (tutorial, blog_post, landing_page, cold_email, battle_card).",
|
|
86
|
+
),
|
|
87
|
+
timeout_seconds: float = typer.Option(
|
|
88
|
+
600.0,
|
|
89
|
+
"--timeout",
|
|
90
|
+
min=0.1,
|
|
91
|
+
help="Maximum seconds to wait for Kai before exiting with a clear timeout.",
|
|
92
|
+
),
|
|
93
|
+
editorial_mode: str = typer.Option(
|
|
94
|
+
"fast",
|
|
95
|
+
"--editorial-mode",
|
|
96
|
+
help="Generation path: fast for a bounded first draft, full for the full editorial pipeline.",
|
|
97
|
+
),
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Generate new content through Kai: KB-grounded prompt + grounding/code validation."""
|
|
100
|
+
editorial_mode = editorial_mode.strip().lower()
|
|
101
|
+
if editorial_mode == "full":
|
|
102
|
+
editorial_mode = "pipeline"
|
|
103
|
+
if editorial_mode not in {"fast", "pipeline"}:
|
|
104
|
+
console.print("[red]--editorial-mode must be 'fast' or 'full'.[/red]")
|
|
105
|
+
raise typer.Exit(code=1)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
root = find_devrel_root()
|
|
109
|
+
except ProjectNotFoundError as e:
|
|
110
|
+
console.print(f"[red]{e}[/red]")
|
|
111
|
+
raise typer.Exit(code=1) from None
|
|
112
|
+
paths = ProjectPaths.from_root(root)
|
|
113
|
+
client = _build_llm_client(paths)
|
|
114
|
+
kai = _build_kai(paths, client)
|
|
115
|
+
|
|
116
|
+
async def _do() -> None:
|
|
117
|
+
console.print(
|
|
118
|
+
f"[cyan]Generating with Kai[/cyan] "
|
|
119
|
+
f"({content_type}, {editorial_mode}, timeout {timeout_seconds:g}s)..."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def _heartbeat() -> None:
|
|
123
|
+
elapsed = 0
|
|
124
|
+
while True:
|
|
125
|
+
await asyncio.sleep(30)
|
|
126
|
+
elapsed += 30
|
|
127
|
+
console.print(f"[dim]Still generating... {elapsed}s elapsed[/dim]")
|
|
128
|
+
|
|
129
|
+
heartbeat = asyncio.create_task(_heartbeat())
|
|
130
|
+
try:
|
|
131
|
+
result = await asyncio.wait_for(
|
|
132
|
+
kai.execute(
|
|
133
|
+
task=prompt,
|
|
134
|
+
content_type=content_type,
|
|
135
|
+
editorial_mode=editorial_mode,
|
|
136
|
+
),
|
|
137
|
+
timeout=timeout_seconds,
|
|
138
|
+
)
|
|
139
|
+
except TimeoutError:
|
|
140
|
+
console.print(
|
|
141
|
+
f"[red]Kai timed out after {timeout_seconds:g}s.[/red] "
|
|
142
|
+
"Try a narrower prompt, add more focused KB evidence, or increase --timeout."
|
|
143
|
+
)
|
|
144
|
+
raise typer.Exit(code=1) from None
|
|
145
|
+
finally:
|
|
146
|
+
heartbeat.cancel()
|
|
147
|
+
with suppress(asyncio.CancelledError):
|
|
148
|
+
await heartbeat
|
|
149
|
+
|
|
150
|
+
status = result.get("status")
|
|
151
|
+
body = result.get("content") or ""
|
|
152
|
+
if status != "generated" or not body:
|
|
153
|
+
console.print(f"[red]Kai did not produce content (status={status}).[/red]")
|
|
154
|
+
for gap in result.get("evidence_gaps", []):
|
|
155
|
+
console.print(f" - {gap}")
|
|
156
|
+
if result.get("error"):
|
|
157
|
+
console.print(f" error: {result['error']}")
|
|
158
|
+
raise typer.Exit(code=1)
|
|
159
|
+
|
|
160
|
+
trace = {
|
|
161
|
+
"agent": "kai",
|
|
162
|
+
"task": result.get("task"),
|
|
163
|
+
"content_type": content_type,
|
|
164
|
+
"editorial_mode": result.get("editorial_mode", editorial_mode),
|
|
165
|
+
"grounding_sources": result.get("grounding_sources", []),
|
|
166
|
+
"pain_points_addressed": result.get("pain_points_addressed", []),
|
|
167
|
+
"real_issues_referenced": result.get("real_issues_referenced", []),
|
|
168
|
+
"revision": result.get("revision", {}),
|
|
169
|
+
"code_validation": result.get("code_validation", {}),
|
|
170
|
+
"grounding_validation": result.get("grounding_validation", {}),
|
|
171
|
+
"status": status,
|
|
172
|
+
}
|
|
173
|
+
body_path, trace_path = _write_outputs(paths, _slug(prompt), body, trace)
|
|
174
|
+
console.print(f"[green]✓[/green] Wrote {body_path.name} ({len(body)} chars)")
|
|
175
|
+
console.print(f"[green]✓[/green] Wrote {trace_path.name}")
|
|
176
|
+
|
|
177
|
+
sources = result.get("grounding_sources") or []
|
|
178
|
+
if sources:
|
|
179
|
+
console.print(f"[green]✓[/green] Grounded in {len(sources)} KB doc(s)")
|
|
180
|
+
else:
|
|
181
|
+
console.print(
|
|
182
|
+
"[yellow]⚠[/yellow] No KB sources matched the prompt; "
|
|
183
|
+
"output may be ungrounded. Run `devrel kb add` to populate the KB."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
cv = result.get("code_validation") or {}
|
|
187
|
+
if cv and not cv.get("all_passed", True):
|
|
188
|
+
console.print(
|
|
189
|
+
f"[yellow]⚠[/yellow] Code validation: "
|
|
190
|
+
f"{cv.get('failed', 0)}/{cv.get('validated', 0)} blocks failed syntax checks"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
asyncio.run(_do())
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@content_app.command("slop")
|
|
197
|
+
def slop_command(
|
|
198
|
+
file: Path = typer.Argument(..., exists=True, readable=True, help="File to lint for slop."),
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Run the deterministic regex slop blocklist against a file. Exits
|
|
201
|
+
nonzero if any blocklisted phrase is hit. No LLM calls."""
|
|
202
|
+
try:
|
|
203
|
+
root = find_devrel_root()
|
|
204
|
+
except ProjectNotFoundError as e:
|
|
205
|
+
console.print(f"[red]{e}[/red]")
|
|
206
|
+
raise typer.Exit(code=1) from None
|
|
207
|
+
paths = ProjectPaths.from_root(root)
|
|
208
|
+
if not paths.slop_file.is_file():
|
|
209
|
+
console.print(f"[red]No slop blocklist at {paths.slop_file}[/red]")
|
|
210
|
+
raise typer.Exit(code=1)
|
|
211
|
+
blocklist = parse_blocklist(paths.slop_file.read_text())
|
|
212
|
+
text = file.read_text()
|
|
213
|
+
hits = find_slop(text, blocklist)
|
|
214
|
+
if not hits:
|
|
215
|
+
console.print(
|
|
216
|
+
f"[green]✓[/green] {file.name}: no slop hits ({len(blocklist)} phrases checked)"
|
|
217
|
+
)
|
|
218
|
+
return
|
|
219
|
+
console.print(f"[red]✗[/red] {file.name}: {len(hits)} slop hit(s)")
|
|
220
|
+
for h in hits[:50]:
|
|
221
|
+
console.print(f" [yellow]{h.phrase!r}[/yellow] at offset {h.start}")
|
|
222
|
+
raise typer.Exit(code=1)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@content_app.command("audit")
|
|
226
|
+
def audit_command(
|
|
227
|
+
file: Path = typer.Argument(..., exists=True, readable=True, help="Existing draft to audit."),
|
|
228
|
+
content_type: str = typer.Option("tutorial", "--type"),
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Run the editorial pipeline against an existing draft file."""
|
|
231
|
+
try:
|
|
232
|
+
root = find_devrel_root()
|
|
233
|
+
except ProjectNotFoundError as e:
|
|
234
|
+
console.print(f"[red]{e}[/red]")
|
|
235
|
+
raise typer.Exit(code=1) from None
|
|
236
|
+
paths = ProjectPaths.from_root(root)
|
|
237
|
+
client = _build_llm_client(paths)
|
|
238
|
+
|
|
239
|
+
async def _do() -> None:
|
|
240
|
+
body = file.read_text()
|
|
241
|
+
try:
|
|
242
|
+
result = await run_pipeline(
|
|
243
|
+
initial_draft=body,
|
|
244
|
+
content_type=content_type,
|
|
245
|
+
project_paths=paths,
|
|
246
|
+
llm_client=client,
|
|
247
|
+
)
|
|
248
|
+
except AbortLoud as e:
|
|
249
|
+
console.print(f"[red]Pipeline aborted: {e}[/red]")
|
|
250
|
+
raise typer.Exit(code=1) from None
|
|
251
|
+
body_path, trace_path = _write_outputs(
|
|
252
|
+
paths, _slug(file.stem), result.final_text, result.revision_trace
|
|
253
|
+
)
|
|
254
|
+
console.print(f"[green]✓[/green] Wrote {body_path.name}")
|
|
255
|
+
console.print(f"[green]✓[/green] Wrote {trace_path.name}")
|
|
256
|
+
if result.flagged:
|
|
257
|
+
console.print("[yellow]⚠[/yellow] Flagged.")
|
|
258
|
+
|
|
259
|
+
asyncio.run(_do())
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""`devrel cost` — read the costs ledger from .devrel/state.db.
|
|
2
|
+
|
|
3
|
+
Reads the SQLite `costs` table populated by the LLM cost sink (Phase 4
|
|
4
|
+
Task 1). Reports total spend in USD, plus a per-agent breakdown. No
|
|
5
|
+
ANTHROPIC_API_KEY required — this only reads local state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from devrel_origin.cli._common import find_paths_or_exit
|
|
16
|
+
from devrel_origin.project.state import open_db
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cost_command(
|
|
22
|
+
month: str = typer.Option(
|
|
23
|
+
"",
|
|
24
|
+
"--month",
|
|
25
|
+
help="Filter to a YYYY-MM slice (e.g., '2026-04').",
|
|
26
|
+
),
|
|
27
|
+
json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Show recorded LLM cost totals from the project state DB."""
|
|
30
|
+
paths = find_paths_or_exit(console)
|
|
31
|
+
if not paths.state_db.is_file():
|
|
32
|
+
console.print("[yellow]No state.db yet. Run an agent first.[/yellow]")
|
|
33
|
+
if json_output:
|
|
34
|
+
typer.echo(
|
|
35
|
+
json.dumps(
|
|
36
|
+
{
|
|
37
|
+
"total_usd": 0.0,
|
|
38
|
+
"by_agent": {},
|
|
39
|
+
"calls": 0,
|
|
40
|
+
"month_filter": month or None,
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
# Build a parameterised WHERE clause. The `where` literal is one of two
|
|
47
|
+
# fixed strings we control — user input flows only through `params`,
|
|
48
|
+
# so SQL injection is impossible.
|
|
49
|
+
where = ""
|
|
50
|
+
params: tuple = ()
|
|
51
|
+
if month:
|
|
52
|
+
where = "WHERE recorded_at LIKE ?"
|
|
53
|
+
params = (f"{month}%",)
|
|
54
|
+
|
|
55
|
+
with open_db(paths.state_db) as conn:
|
|
56
|
+
total_row = conn.execute(
|
|
57
|
+
f"SELECT COALESCE(SUM(cost_usd), 0.0) AS total, COUNT(*) AS calls FROM costs {where}",
|
|
58
|
+
params,
|
|
59
|
+
).fetchone()
|
|
60
|
+
total_usd = float(total_row["total"]) if total_row else 0.0
|
|
61
|
+
calls = int(total_row["calls"]) if total_row else 0
|
|
62
|
+
|
|
63
|
+
by_agent_rows = conn.execute(
|
|
64
|
+
f"SELECT agent, "
|
|
65
|
+
f"COALESCE(SUM(cost_usd), 0.0) AS usd, "
|
|
66
|
+
f"COALESCE(SUM(input_tokens), 0) AS in_tok, "
|
|
67
|
+
f"COALESCE(SUM(output_tokens), 0) AS out_tok, "
|
|
68
|
+
f"COUNT(*) AS calls "
|
|
69
|
+
f"FROM costs {where} GROUP BY agent ORDER BY usd DESC",
|
|
70
|
+
params,
|
|
71
|
+
).fetchall()
|
|
72
|
+
|
|
73
|
+
by_agent = {
|
|
74
|
+
r["agent"]: {
|
|
75
|
+
"usd": float(r["usd"]),
|
|
76
|
+
"input_tokens": int(r["in_tok"]),
|
|
77
|
+
"output_tokens": int(r["out_tok"]),
|
|
78
|
+
"calls": int(r["calls"]),
|
|
79
|
+
}
|
|
80
|
+
for r in by_agent_rows
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if json_output:
|
|
84
|
+
typer.echo(
|
|
85
|
+
json.dumps(
|
|
86
|
+
{
|
|
87
|
+
"total_usd": total_usd,
|
|
88
|
+
"calls": calls,
|
|
89
|
+
"by_agent": by_agent,
|
|
90
|
+
"month_filter": month or None,
|
|
91
|
+
},
|
|
92
|
+
indent=2,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
suffix = f" for {month}" if month else ""
|
|
98
|
+
console.print(f"[bold]Total{suffix}:[/bold] ${total_usd:.4f} [dim]({calls} call(s))[/dim]")
|
|
99
|
+
if not by_agent:
|
|
100
|
+
console.print("[dim]No cost rows yet.[/dim]")
|
|
101
|
+
return
|
|
102
|
+
console.print("\n[bold]By agent:[/bold]")
|
|
103
|
+
for agent, row in by_agent.items():
|
|
104
|
+
console.print(
|
|
105
|
+
f" {agent:>10s} ${row['usd']:.4f} "
|
|
106
|
+
f"[dim]in={row['input_tokens']} out={row['output_tokens']} "
|
|
107
|
+
f"calls={row['calls']}[/dim]"
|
|
108
|
+
)
|