devrel-origin 0.2.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. devrel_origin/__init__.py +15 -0
  2. devrel_origin/cli/__init__.py +92 -0
  3. devrel_origin/cli/_common.py +243 -0
  4. devrel_origin/cli/analytics.py +28 -0
  5. devrel_origin/cli/argus.py +497 -0
  6. devrel_origin/cli/auth.py +227 -0
  7. devrel_origin/cli/config.py +108 -0
  8. devrel_origin/cli/content.py +259 -0
  9. devrel_origin/cli/cost.py +108 -0
  10. devrel_origin/cli/cro.py +298 -0
  11. devrel_origin/cli/deliverables.py +65 -0
  12. devrel_origin/cli/docs.py +91 -0
  13. devrel_origin/cli/doctor.py +178 -0
  14. devrel_origin/cli/experiment.py +29 -0
  15. devrel_origin/cli/growth.py +97 -0
  16. devrel_origin/cli/init.py +472 -0
  17. devrel_origin/cli/intel.py +27 -0
  18. devrel_origin/cli/kb.py +96 -0
  19. devrel_origin/cli/listen.py +31 -0
  20. devrel_origin/cli/marketing.py +66 -0
  21. devrel_origin/cli/migrate.py +45 -0
  22. devrel_origin/cli/run.py +46 -0
  23. devrel_origin/cli/sales.py +57 -0
  24. devrel_origin/cli/schedule.py +62 -0
  25. devrel_origin/cli/synthesize.py +28 -0
  26. devrel_origin/cli/triage.py +29 -0
  27. devrel_origin/cli/video.py +35 -0
  28. devrel_origin/core/__init__.py +58 -0
  29. devrel_origin/core/agent_config.py +75 -0
  30. devrel_origin/core/argus.py +964 -0
  31. devrel_origin/core/atlas.py +1450 -0
  32. devrel_origin/core/base.py +372 -0
  33. devrel_origin/core/cyra.py +563 -0
  34. devrel_origin/core/dex.py +708 -0
  35. devrel_origin/core/echo.py +614 -0
  36. devrel_origin/core/growth/__init__.py +27 -0
  37. devrel_origin/core/growth/recommendations.py +219 -0
  38. devrel_origin/core/growth/target_kinds.py +51 -0
  39. devrel_origin/core/iris.py +513 -0
  40. devrel_origin/core/kai.py +1367 -0
  41. devrel_origin/core/llm.py +542 -0
  42. devrel_origin/core/llm_backends.py +274 -0
  43. devrel_origin/core/mox.py +514 -0
  44. devrel_origin/core/nova.py +349 -0
  45. devrel_origin/core/pax.py +1205 -0
  46. devrel_origin/core/rex.py +532 -0
  47. devrel_origin/core/sage.py +486 -0
  48. devrel_origin/core/sentinel.py +385 -0
  49. devrel_origin/core/types.py +98 -0
  50. devrel_origin/core/video/__init__.py +22 -0
  51. devrel_origin/core/video/assembler.py +131 -0
  52. devrel_origin/core/video/browser_recorder.py +118 -0
  53. devrel_origin/core/video/desktop_recorder.py +254 -0
  54. devrel_origin/core/video/overlay_renderer.py +143 -0
  55. devrel_origin/core/video/script_parser.py +147 -0
  56. devrel_origin/core/video/tts_engine.py +82 -0
  57. devrel_origin/core/vox.py +268 -0
  58. devrel_origin/core/watchdog.py +321 -0
  59. devrel_origin/project/__init__.py +1 -0
  60. devrel_origin/project/config.py +75 -0
  61. devrel_origin/project/cost_sink.py +61 -0
  62. devrel_origin/project/init.py +104 -0
  63. devrel_origin/project/paths.py +75 -0
  64. devrel_origin/project/state.py +241 -0
  65. devrel_origin/project/templates/__init__.py +4 -0
  66. devrel_origin/project/templates/config.toml +24 -0
  67. devrel_origin/project/templates/devrel.gitignore +10 -0
  68. devrel_origin/project/templates/slop-blocklist.md +45 -0
  69. devrel_origin/project/templates/style.md +24 -0
  70. devrel_origin/project/templates/voice.md +29 -0
  71. devrel_origin/quality/__init__.py +66 -0
  72. devrel_origin/quality/editorial.py +357 -0
  73. devrel_origin/quality/persona.py +84 -0
  74. devrel_origin/quality/readability.py +148 -0
  75. devrel_origin/quality/slop.py +167 -0
  76. devrel_origin/quality/style.py +110 -0
  77. devrel_origin/quality/voice.py +15 -0
  78. devrel_origin/tools/__init__.py +9 -0
  79. devrel_origin/tools/analytics.py +304 -0
  80. devrel_origin/tools/api_client.py +393 -0
  81. devrel_origin/tools/apollo_client.py +305 -0
  82. devrel_origin/tools/code_validator.py +428 -0
  83. devrel_origin/tools/github_tools.py +297 -0
  84. devrel_origin/tools/instantly_client.py +412 -0
  85. devrel_origin/tools/kb_harvester.py +340 -0
  86. devrel_origin/tools/mcp_server.py +578 -0
  87. devrel_origin/tools/notifications.py +245 -0
  88. devrel_origin/tools/run_report.py +193 -0
  89. devrel_origin/tools/scheduler.py +231 -0
  90. devrel_origin/tools/search_tools.py +321 -0
  91. devrel_origin/tools/self_improve.py +168 -0
  92. devrel_origin/tools/sheets.py +236 -0
  93. devrel_origin-0.2.14.dist-info/METADATA +354 -0
  94. devrel_origin-0.2.14.dist-info/RECORD +98 -0
  95. devrel_origin-0.2.14.dist-info/WHEEL +5 -0
  96. devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
  97. devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
  98. devrel_origin-0.2.14.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ )