feat(my-deepagent): v0.1.0 Step 6~15 — REPL/Budget/Recovery/Audit/Pricing + real OpenRouter E2E

Step 6  — Distribution: init/login/logout/keys/doctor CLI, platformdirs data dirs,
          OS keyring (Keychain/Secret Service/Credential Store), first-run governance
          consent, secret resolution chain (config→env→keyring), ko/en i18n catalog
          via MYDEEPAGENT_LANG.
Step 7  — WorkflowEngine: phase loop, ArtifactWatcherMiddleware (write_file/edit_file
          detection), jsonschema 2020-12 validation + 1 repair retry, approval gate,
          final report compose (JSON + Markdown). FK-safe persistence ordering.
          RunEventType + run_idempotency_key per plan v2.0 §13.1.
Step 8  — Budget guardrails: BudgetTracker (SQLite WAL ledger, block/warn_continue/
          prompt policies, per-run + per-day + per-persona-daily scopes), cost preview
          before run (rich table), CostMiddleware wired with pre-call assert + post-call
          record. CLI: budget / stats --by model|persona|day / costs.
Step 9  — Crash recovery + concurrency: sweep_orphan_runs() at startup (frees the
          ux_active_run_repo_base partial unique slot), `runs list/show/resume` CLI,
          SIGTERM/SIGINT graceful shutdown (30s grace then cancel), auto-sweep before
          new phase.
Step 10 — Interactive REPL: `mydeepagent` (no subcommand) launches prompt_toolkit REPL
          with --agent/--model overrides, slash commands (/help /quit /agent /model
          /clear /stats /budget /runs), @file-ref expansion (repo-root containment),
          CostMiddleware-wired per-session metering.
Step 11 — Audit log + secret scrubbing: append-only {state_dir}/audit.jsonl per tool
          call, AuditToolMiddleware with file_recorder, structlog _scrub_processor
          redacting OpenRouter/Anthropic/OpenAI/LangSmith/GitHub/GitLab keys + Bearer
          tokens before stderr/JSON sinks.
Step 12 — Doctor 8-check + OpenRouter pricing fetch: 8-check doctor (python/uv/git/
          workspace_root/config+governance/openrouter_api_key/openrouter_ping+pricing
          upsert/disk+sqlite integrity), `mydeepagent pricing` cache view, run preview
          reads persisted model_pricing with static seed fallback.
Step 15 — End-to-end real OpenRouter integration: tests/integration/test_e2e_workflow.py
          runs spec-and-review@1 (spec → review → verify) end-to-end against real
          OpenRouter DeepSeek in ~71s for ~$0.05 per run. BindingOverride pins all 3
          roles to DeepSeek personas to sidestep the langchain-openai + Anthropic-via-
          OpenRouter tool_calls.args JSON-string ValidationError (known v0.1.0 limit).
          New personas: openrouter-deepseek-spec-writer@1, openrouter-deepseek-code-
          reviewer@1 (+ fake-reviewer@1 fixture). _build_envelope inlines the JSON
          Schema so the LLM sees exact required fields. _record_llm_call fills every
          NOT NULL LlmCallRow column. CostMiddleware probes both usage_metadata and
          response_metadata.token_usage (prompt_tokens/completion_tokens fallback).
          dev/review-finding-batch@1 artifact schema added.

Known v0.1.0 limits documented in CHANGELOG:
- usage_metadata sometimes empty on OpenRouter-forwarded responses (recorder still
  fires, row persisted, but tokens may read 0). v0.2 will probe more response shapes.
- Anthropic via OpenRouter currently fails with tool_calls.args JSON-string vs dict
  ValidationError in langchain-openai → DeepSeek workaround required.
- `runs resume <run_id>` is a stub (exit-2 hint only).

Gates: ruff check / ruff format --check / mypy --strict / 574 pytest PASS (5.29s)
plus 1 E2E PASS (71.21s, real OpenRouter, ~\$0.05).

--no-verify used: lefthook still TS-only (TS code in packages/ pending removal per
plan-v4-draft.md Step 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chungyeong
2026-05-16 16:32:46 +09:00
parent 17ba5d723b
commit 733c9be0bd
66 changed files with 8286 additions and 100 deletions

View File

@@ -1 +1,244 @@
"""CLI doctor command for environment diagnostics. Implemented in Step 12."""
"""mydeepagent doctor — full 8-check environment diagnostic.
Checks:
1. Python 3.12+ <3.14
2. uv >= 0.5
3. git >= 2.40
4. WORKSPACE_ROOT writable
5. config + governance consent
6. OpenRouter API key reachable
7. OpenRouter /models ping + pricing matrix upsert
8. Disk free + SQLite integrity_check
"""
from __future__ import annotations
import asyncio
import shutil
import subprocess
import sys
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Literal
import httpx
import typer
from rich.console import Console
from rich.table import Table
from sqlalchemy import text as sa_text
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from ..config import Config, load_config
from ..errors import MyDeepAgentError
from ..governance import has_consent
from ..i18n import t
from ..monitoring.pricing import (
ModelPrice,
fetch_openrouter_pricing,
)
from ..persistence.db import Database
from ..persistence.models import ModelPricingRow
from ..secrets import resolve_openrouter_api_key
_CONSOLE = Console()
@dataclass(frozen=True)
class CheckResult:
name: str
status: Literal["ok", "warn", "fail"]
detail: str = ""
def _check_python() -> CheckResult:
if (3, 12) <= sys.version_info[:2] < (3, 14):
return CheckResult("python", "ok", f"v{sys.version.split()[0]}")
return CheckResult(
"python",
"fail",
f"need 3.12<=x<3.14, got {sys.version.split()[0]}",
)
def _check_uv() -> CheckResult:
path = shutil.which("uv")
if not path:
return CheckResult("uv", "warn", "not on PATH (only needed for dev workflows)")
try:
result = subprocess.run( # noqa: S603
[path, "--version"], capture_output=True, text=True, timeout=5
)
except (OSError, subprocess.TimeoutExpired) as e:
return CheckResult("uv", "warn", f"version probe failed: {e}")
version = result.stdout.strip()
return CheckResult("uv", "ok", version or path)
def _check_git() -> CheckResult:
path = shutil.which("git")
if not path:
return CheckResult("git", "warn", "not on PATH (workflows may use git tools)")
try:
result = subprocess.run( # noqa: S603
[path, "--version"], capture_output=True, text=True, timeout=5
)
except (OSError, subprocess.TimeoutExpired) as e:
return CheckResult("git", "warn", f"version probe failed: {e}")
return CheckResult("git", "ok", result.stdout.strip())
def _check_workspace(config: Config) -> CheckResult:
root = config.workspace_root
if not root.exists():
try:
root.mkdir(parents=True, exist_ok=True)
except OSError as e:
return CheckResult("workspace_root", "fail", f"cannot create: {e}")
try:
probe = root / ".doctor_probe"
probe.write_text("ok", encoding="utf-8")
probe.unlink()
except OSError as e:
return CheckResult("workspace_root", "fail", f"not writable: {e}")
return CheckResult("workspace_root", "ok", str(root))
def _check_config_and_governance(config: Config) -> CheckResult:
if not has_consent(config.data_dir):
return CheckResult(
"config+governance",
"fail",
"governance not accepted — run `mydeepagent init`",
)
return CheckResult("config+governance", "ok", f"data_dir={config.data_dir}")
def _check_openrouter_api_key(config: Config) -> CheckResult:
try:
key = resolve_openrouter_api_key(config)
except MyDeepAgentError as e:
hint = e.recovery_hint or str(e)
return CheckResult("openrouter_api_key", "fail", f"missing: {hint}")
return CheckResult("openrouter_api_key", "ok", f"resolved ({len(key)} chars)")
async def _check_openrouter_ping_and_upsert(config: Config) -> CheckResult:
try:
key = resolve_openrouter_api_key(config)
except MyDeepAgentError:
return CheckResult("openrouter_ping", "warn", "skipped — no API key (see previous check)")
try:
prices = await fetch_openrouter_pricing(key, config.openrouter_base_url)
except MyDeepAgentError as e:
return CheckResult("openrouter_ping", "warn", f"fetch failed: {e}")
except httpx.HTTPStatusError as e:
if e.response.status_code == 401:
return CheckResult("openrouter_ping", "fail", "401 — API key invalid")
return CheckResult("openrouter_ping", "warn", f"http {e.response.status_code}")
if not prices:
return CheckResult("openrouter_ping", "warn", "no models in response payload")
await _upsert_pricing(config, prices)
return CheckResult("openrouter_ping", "ok", f"{len(prices)} models cached")
async def _upsert_pricing(config: Config, prices: list[ModelPrice]) -> None:
db = Database(config.database_url)
await db.init_schema()
now = datetime.now(UTC).isoformat(timespec="seconds")
try:
async with db.session() as s:
for p in prices:
stmt = (
sqlite_insert(ModelPricingRow)
.values(
model=p.model,
input_per_1k_usd=p.input_per_1k_usd,
output_per_1k_usd=p.output_per_1k_usd,
context_length=p.context_length,
fetched_at=now,
raw_payload="",
)
.on_conflict_do_update(
index_elements=["model"],
set_={
"input_per_1k_usd": p.input_per_1k_usd,
"output_per_1k_usd": p.output_per_1k_usd,
"context_length": p.context_length,
"fetched_at": now,
},
)
)
await s.execute(stmt)
await s.commit()
finally:
await db.dispose()
async def _check_disk_and_db(config: Config) -> CheckResult:
usage = shutil.disk_usage(str(config.workspace_root))
free_gb = usage.free / (1024**3)
if free_gb < 2.0:
disk_status: Literal["ok", "warn", "fail"] = "fail"
elif free_gb < 10.0:
disk_status = "warn"
else:
disk_status = "ok"
db = Database(config.database_url)
await db.init_schema()
try:
async with db.session() as s:
row = (await s.execute(sa_text("PRAGMA integrity_check"))).scalar_one()
finally:
await db.dispose()
db_ok = row == "ok"
detail = f"free={free_gb:.1f}GB, sqlite_integrity={'ok' if db_ok else str(row)}"
if disk_status == "fail" or not db_ok:
final: Literal["ok", "warn", "fail"] = "fail"
elif disk_status == "warn":
final = "warn"
else:
final = "ok"
return CheckResult("disk+db", final, detail)
def doctor_command() -> None:
asyncio.run(_doctor_async())
async def _doctor_async() -> None:
try:
config = load_config()
except MyDeepAgentError as e:
_CONSOLE.print(f"[red]config load failed: {e}[/]")
raise typer.Exit(code=1) from None
checks: list[CheckResult] = []
checks.append(_check_python())
checks.append(_check_uv())
checks.append(_check_git())
checks.append(_check_workspace(config))
checks.append(_check_config_and_governance(config))
checks.append(_check_openrouter_api_key(config))
checks.append(await _check_openrouter_ping_and_upsert(config))
checks.append(await _check_disk_and_db(config))
_render(checks)
has_fail = any(c.status == "fail" for c in checks)
if has_fail:
raise typer.Exit(code=1)
def _render(checks: list[CheckResult]) -> None:
title = t("doctor.header") or "Environment diagnostics:"
table = Table(title=title)
table.add_column("Check")
table.add_column("Status")
table.add_column("Detail")
color_map: dict[str, str] = {"ok": "green", "warn": "yellow", "fail": "red"}
for c in checks:
color = color_map[c.status]
table.add_row(c.name, f"[{color}]{c.status}[/]", c.detail)
_CONSOLE.print(table)

View File

@@ -0,0 +1,39 @@
"""mydeepagent init: first-run wizard."""
from __future__ import annotations
import typer
from rich.console import Console
from ..config import load_config
from ..governance import has_consent, record_consent
from ..i18n import t
from ..keys import set_api_key
from .doctor import doctor_command
_CONSOLE = Console()
def init_command() -> None:
config = load_config()
_CONSOLE.print(f"[bold cyan]{t('init.welcome')}[/]")
_CONSOLE.print()
if not has_consent(config.data_dir):
_CONSOLE.print(f"[yellow]{t('init.governance_title')}[/]")
_CONSOLE.print(t("init.governance_body"))
answer = typer.prompt(t("init.governance_prompt"))
if answer.strip().lower() != "yes":
_CONSOLE.print(f"[red]{t('init.governance_declined')}[/]")
raise typer.Exit(code=1)
record_consent(config.data_dir)
api_key = typer.prompt(t("init.api_key_prompt"), hide_input=True, default="")
if api_key.strip():
set_api_key("openrouter", api_key.strip())
_CONSOLE.print(f"[green]{t('init.api_key_saved')}[/]")
else:
_CONSOLE.print(f"[yellow]{t('init.api_key_empty')}[/]")
_CONSOLE.print()
_CONSOLE.print(t("init.doctor_running"))
doctor_command()
_CONSOLE.print()
_CONSOLE.print(f"[bold green]{t('init.done')}[/]")

View File

@@ -1 +1,367 @@
"""CLI interactive subcommand. Implemented in Step 10."""
"""mydeepagent (no subcommand) — interactive REPL.
prompt_toolkit-based REPL. Slash commands for navigation; everything else
goes to the bound agent. File refs ``@path/to/file.py`` are expanded into
markdown code blocks inline before the message is sent.
"""
from __future__ import annotations
import asyncio
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import UUID, uuid4
from prompt_toolkit import PromptSession
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.history import FileHistory
from rich.console import Console
from ..audit import make_audit_recorder
from ..budget import make_budget_tracker_from_config
from ..config import Config, load_config
from ..governance import require_consent
from ..middleware.audit import AuditToolMiddleware
from ..middleware.cost import CostMiddleware
from ..monitoring.pricing import ModelPrice, PricingCache
from ..persistence.db import Database
from ..persona import Persona, load_personas_from_dir
from ..session import build_agent
from ..slash import SlashParsed, SlashRegistry, parse_slash
_CONSOLE = Console()
_FILE_REF_PATTERN = re.compile(r"(?<![\w./])@([\w./\-]+)")
def _seed_root() -> Path:
return Path(__file__).resolve().parents[3] / "docs" / "schemas"
def _history_path(config: Config) -> Path:
p = config.state_dir
p.mkdir(parents=True, exist_ok=True)
return p / "history.txt"
def _expand_file_refs(text: str, repo_root: Path) -> str:
"""Replace ``@path`` tokens with the file contents in fenced markdown blocks.
Silently skips paths that escape the repo root or don't exist.
"""
def _replace(match: re.Match[str]) -> str:
rel = match.group(1)
target = (repo_root / rel).resolve()
try:
target.relative_to(repo_root.resolve())
except ValueError:
return match.group(0)
if not target.is_file():
return match.group(0)
try:
content = target.read_text(encoding="utf-8", errors="replace")
except OSError:
return match.group(0)
suffix = target.suffix.lstrip(".") or ""
return f"\n```{suffix}\n# {rel}\n{content}\n```\n"
return _FILE_REF_PATTERN.sub(_replace, text)
def _static_pricing_seed() -> PricingCache:
"""Minimal pricing matrix for v0.1.0 (full fetch is Step 12).
Unit: USD per 1,000 tokens.
"""
cache = PricingCache()
cache.set(
[
ModelPrice("anthropic/claude-sonnet-4-6", 0.003, 0.015, 200_000),
ModelPrice("anthropic/claude-haiku-4-5", 0.001, 0.005, 200_000),
ModelPrice("anthropic/claude-opus-4-1", 0.015, 0.075, 200_000),
ModelPrice("deepseek/deepseek-chat", 0.00028, 0.00112, 64_000),
]
)
return cache
def _now_iso() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
class InteractiveSession:
"""Holds REPL state: current persona, current model override, history, agent."""
def __init__(
self,
config: Config,
personas: list[Persona],
db: Database,
pricing: PricingCache,
repo_root: Path,
session_id: UUID,
) -> None:
self.config = config
self.personas = personas
self.db = db
self.pricing = pricing
self.repo_root = repo_root
self.session_id = session_id
self._model_override: str | None = None
self._persona = self._default_persona()
self._agent: Any | None = None
def _default_persona(self) -> Persona:
name = self.config.default_persona
for p in self.personas:
if p.name == name:
return p
if not self.personas:
raise RuntimeError(
"no personas seeded; run `mydeepagent init` or seed docs/schemas/personas/"
)
return self.personas[0]
@property
def persona(self) -> Persona:
return self._persona
@property
def model_override(self) -> str | None:
return self._model_override
def set_persona(self, name: str) -> Persona:
for p in self.personas:
if p.name == name or f"{p.name}@{p.version}" == name:
self._persona = p
self._agent = None # rebuild on next turn
return p
raise ValueError(f"persona not found: {name!r}")
def set_model(self, model: str | None) -> None:
self._model_override = model
self._agent = None
def clear_agent_cache(self) -> None:
"""Flush the cached agent so the next call rebuilds with a fresh thread."""
self._agent = None
def build_agent_if_needed(self) -> Any:
if self._agent is not None:
return self._agent
budget = make_budget_tracker_from_config(self.db, self.config)
cost_mw = CostMiddleware(
pricing=self.pricing,
model_name=self._model_override or self._persona.model,
interactive_session_id=self.session_id,
persona_name=self._persona.name,
budget_tracker=budget,
)
audit_mw = AuditToolMiddleware(
interactive_session_id=self.session_id,
file_recorder=make_audit_recorder(self.config.state_dir),
)
self._agent = build_agent(
self._persona,
self.config,
root_dir=self.repo_root,
middleware=[cost_mw, audit_mw],
model_override=self._model_override,
)
return self._agent
def _register_navigation_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
"""Register /quit, /exit, /help, /clear slash handlers."""
async def _quit(_: SlashParsed) -> bool:
return True
async def _help(_: SlashParsed) -> bool:
_CONSOLE.print("[bold]Slash commands:[/]")
for name, desc in reg.all_help():
_CONSOLE.print(f" /{name:14s} {desc}")
return False
async def _clear(_: SlashParsed) -> bool:
sess.clear_agent_cache()
_CONSOLE.print("[dim]context cleared (new session thread)[/]")
return False
reg.register("quit", _quit, help="exit the REPL")
reg.register("exit", _quit, help="alias for /quit")
reg.register("help", _help, help="show slash commands")
reg.register("clear", _clear, help="clear conversation context")
def _register_persona_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
"""Register /agent and /model slash handlers."""
async def _agent_cmd(cmd: SlashParsed) -> bool:
if not cmd.args:
_CONSOLE.print(f"current: [cyan]{sess.persona.name}@{sess.persona.version}[/]")
for p in sess.personas:
_CONSOLE.print(f" - {p.name}@{p.version} ({p.backend.value})")
return False
try:
new = sess.set_persona(cmd.args[0])
_CONSOLE.print(f"[green]switched persona → {new.name}@{new.version}[/]")
except ValueError as e:
_CONSOLE.print(f"[red]{e}[/]")
return False
async def _model_cmd(cmd: SlashParsed) -> bool:
if not cmd.args:
cur = sess.model_override or sess.persona.model
_CONSOLE.print(f"current model: [cyan]{cur}[/]")
return False
if cmd.args[0] in ("-", "reset"):
sess.set_model(None)
_CONSOLE.print("[green]model override cleared[/]")
else:
sess.set_model(cmd.args[0])
_CONSOLE.print(f"[green]model → {cmd.args[0]}[/]")
return False
reg.register("agent", _agent_cmd, help="list or switch persona: /agent [name]")
reg.register("model", _model_cmd, help="override model: /model <id> | reset")
def _register_telemetry_slash(reg: SlashRegistry) -> None:
"""Register /stats, /budget, /runs slash handlers."""
async def _stats(_: SlashParsed) -> bool:
from .stats import stats_command
stats_command(by="model", since_days=1)
return False
async def _budget(_: SlashParsed) -> bool:
from .stats import budget_command
budget_command()
return False
async def _runs(_: SlashParsed) -> bool:
from .runs import runs_list_command
runs_list_command(limit=10, state_filter=None)
return False
reg.register("stats", _stats, help="LLM-call stats (last 24h)")
reg.register("budget", _budget, help="budget ledger")
reg.register("runs", _runs, help="list recent workflow runs")
def _register_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
_register_navigation_slash(reg, sess)
_register_persona_slash(reg, sess)
_register_telemetry_slash(reg)
def _completer(personas: list[Persona], slash_names: list[str]) -> WordCompleter:
words = [f"/{n}" for n in slash_names]
words += [p.name for p in personas]
return WordCompleter(words, ignore_case=True, sentence=True)
async def _invoke_and_stream(agent: Any, user_text: str, session_id: UUID) -> None:
"""Invoke the agent and pretty-print the response.
v0.1 keeps it simple — full ainvoke, then print the final message.
Token-level streaming via astream is a Step 16 polish.
"""
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": user_text}]},
config={"configurable": {"thread_id": str(session_id)}},
)
messages = result.get("messages", []) if isinstance(result, dict) else []
if not messages:
return
last = messages[-1]
content: Any = getattr(last, "content", "") or ""
if isinstance(content, list):
content = "\n".join(
(c.get("text", str(c)) if isinstance(c, dict) else str(c)) for c in content
)
_CONSOLE.print(str(content))
async def _repl_loop(
sess: InteractiveSession,
reg: SlashRegistry,
prompt_session: PromptSession[str],
) -> int:
"""Inner REPL loop. Returns 0 on clean exit, non-zero on error."""
while True:
try:
line = await prompt_session.prompt_async("» ")
except (EOFError, KeyboardInterrupt):
_CONSOLE.print()
return 0
line = (line or "").strip()
if not line:
continue
parsed = parse_slash(line)
if parsed is not None:
if parsed.name == "":
_CONSOLE.print("[dim]empty slash command; try /help[/]")
continue
done = await reg.dispatch(parsed)
if done:
return 0
if parsed.name not in reg.names:
_CONSOLE.print(f"[yellow]unknown command: /{parsed.name}[/]")
continue
# Forward to agent.
expanded = _expand_file_refs(line, sess.repo_root)
agent = sess.build_agent_if_needed()
try:
await _invoke_and_stream(agent, expanded, sess.session_id)
except Exception as e:
_CONSOLE.print(f"[red]agent error:[/] {type(e).__name__}: {e}")
async def _interactive_loop_async(persona_override: str | None, model_override: str | None) -> int:
config = load_config()
require_consent(config.data_dir)
db = Database(config.database_url)
await db.init_schema()
personas = load_personas_from_dir(_seed_root() / "personas")
if not personas:
_CONSOLE.print("[red]no personas seeded; run `mydeepagent init`[/]")
return 1
pricing = _static_pricing_seed()
session_id = uuid4()
try:
sess = InteractiveSession(config, personas, db, pricing, Path.cwd(), session_id)
if persona_override:
try:
sess.set_persona(persona_override)
except ValueError as e:
_CONSOLE.print(f"[red]{e}[/]")
return 1
if model_override:
sess.set_model(model_override)
reg = SlashRegistry()
_register_slash(reg, sess)
persona_label = f"{sess.persona.name}@{sess.persona.version}"
_CONSOLE.print(f"[bold cyan]my-deepagent[/] — persona [cyan]{persona_label}[/]")
_CONSOLE.print("[dim]type /help for commands, /quit to exit[/]")
prompt_session: PromptSession[str] = PromptSession(
history=FileHistory(str(_history_path(config))),
completer=_completer(personas, reg.names),
)
return await _repl_loop(sess, reg, prompt_session)
finally:
await db.dispose()
def interactive_command(persona: str | None = None, model: str | None = None) -> int:
"""Entry point for the interactive REPL. Returns an exit code."""
return asyncio.run(_interactive_loop_async(persona, model))

View File

@@ -0,0 +1,40 @@
"""login / logout / keys list commands."""
from __future__ import annotations
import typer
from rich.console import Console
from ..i18n import t
from ..keys import delete_api_key, get_api_key, list_providers, mask, set_api_key
_CONSOLE = Console()
def login_command(provider: str) -> None:
value = typer.prompt(t("login.prompt", provider=provider), hide_input=True, default="")
if not value.strip():
_CONSOLE.print(f"[yellow]{t('login.empty')}[/]")
raise typer.Exit(code=1)
set_api_key(provider, value.strip())
_CONSOLE.print(f"[green]{t('login.saved', provider=provider)}[/]")
def logout_command(provider: str) -> None:
removed = delete_api_key(provider)
if removed:
_CONSOLE.print(f"[green]{t('logout.removed', provider=provider)}[/]")
else:
_CONSOLE.print(f"[yellow]{t('logout.not_found', provider=provider)}[/]")
def keys_list_command() -> None:
_CONSOLE.print(t("keys.header"))
found = False
for provider in list_providers():
value = get_api_key(provider)
if value:
_CONSOLE.print(t("keys.entry", provider=provider, masked=mask(value)))
found = True
if not found:
_CONSOLE.print(t("keys.none"))

View File

@@ -1 +1,150 @@
"""Typer CLI entry point. Filled in Step 6."""
"""my-deepagent CLI entry point."""
from __future__ import annotations
from pathlib import Path
import typer
from .doctor import doctor_command
from .init import init_command
from .keys_cmd import keys_list_command, login_command, logout_command
app = typer.Typer(no_args_is_help=False, add_completion=True)
runs_app = typer.Typer(help="Inspect or resume past runs.")
@runs_app.command("list")
def runs_list(
limit: int = typer.Option(20, help="Number of runs to show"),
state: str | None = typer.Option(None, help="Filter by state"),
) -> None:
"""List recent runs."""
from .runs import runs_list_command
runs_list_command(limit, state)
@runs_app.command("show")
def runs_show(run_id: str = typer.Argument(...)) -> None:
"""Show details for a specific run."""
from .runs import runs_show_command
runs_show_command(run_id)
@runs_app.command("resume")
def runs_resume(run_id: str = typer.Argument(...)) -> None:
"""Resume a paused run (v0.1.0: not implemented — shows status only)."""
from .runs import runs_resume_command
runs_resume_command(run_id)
app.add_typer(runs_app, name="runs")
@app.command()
def init() -> None:
"""First-run setup: governance consent + API key + doctor."""
init_command()
@app.command()
def login(provider: str = typer.Argument("openrouter")) -> None:
"""Store an API key for the given provider in the OS keyring."""
login_command(provider)
@app.command()
def logout(provider: str = typer.Argument("openrouter")) -> None:
"""Remove a stored API key from the OS keyring."""
logout_command(provider)
@app.command(name="keys")
def keys_list() -> None:
"""List registered providers (masked)."""
keys_list_command()
@app.command()
def doctor() -> None:
"""Run environment diagnostics (Python/uv/disk for v0.1.0; full suite in Step 12)."""
doctor_command()
@app.command(name="run")
def run(
workflow_path: Path = typer.Argument(..., help="Path to the workflow yaml"), # noqa: B008
repo: Path = typer.Option(Path.cwd(), help="Repo root"), # noqa: B008
base_branch: str = typer.Option("main", help="Base branch"),
no_preview: bool = typer.Option(False, "--no-preview", help="Skip cost preview"),
) -> None:
"""Execute a workflow template end-to-end."""
from .run import run_command
run_command(workflow_path, repo, base_branch, no_preview)
@app.command()
def stats(
by: str = typer.Option("model", help="model | persona | day"),
since_days: int = typer.Option(7, help="Window size in days"),
) -> None:
"""Aggregate LLM-call stats from the ledger."""
from .stats import stats_command
stats_command(by, since_days)
@app.command()
def budget() -> None:
"""Show the current budget ledger (per-scope spend / cap)."""
from .stats import budget_command
budget_command()
@app.command(name="costs")
def costs() -> None:
"""Alias for `stats --by day` over the last 30 days."""
from .stats import stats_command
stats_command(by="day", since_days=30)
@app.command(name="pricing")
def pricing() -> None:
"""Show cached OpenRouter pricing matrix (populated by `doctor`)."""
from .stats import pricing_command
pricing_command()
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
agent: str | None = typer.Option(None, "--agent", help="Start with a specific persona"),
model: str | None = typer.Option(None, "--model", help="Model override"),
) -> None:
from ..logging import configure_logging
try:
from ..config import load_config
cfg = load_config()
configure_logging(level=cfg.log_level, json_output=False)
except Exception:
configure_logging(level="info", json_output=False)
if ctx.invoked_subcommand is None:
from .interactive import interactive_command
code = interactive_command(agent, model)
raise typer.Exit(code=code)
if __name__ == "__main__":
app()

View File

@@ -1 +1,194 @@
"""CLI run command implementation. Implemented in Step 6."""
"""mydeepagent run <workflow.yaml> — execute a workflow end-to-end."""
from __future__ import annotations
import asyncio
from pathlib import Path
import typer
from rich.console import Console
from rich.table import Table
from sqlalchemy import select
from ..artifact_schema import ArtifactSchemaRegistry
from ..binding import BackendAvailability, PersonaConsentStore, bind_personas
from ..budget import BudgetTracker, make_budget_tracker_from_config
from ..config import Config, load_config
from ..engine import WorkflowEngine
from ..enums import Backend
from ..governance import require_consent
from ..monitoring.cost_estimator import WorkflowCostEstimate, estimate_workflow
from ..monitoring.pricing import ModelPrice, PricingCache
from ..persistence.db import Database
from ..persistence.models import ModelPricingRow
from ..persona import load_personas_from_dir
from ..tui.approval import cli_approval_callback
from ..workflow import load_workflow_yaml
_CONSOLE = Console()
def run_command(
workflow_path: Path,
repo: Path,
base_branch: str,
no_preview: bool = False,
) -> None:
"""Synchronous CLI wrapper for the async engine."""
asyncio.run(_run_async(workflow_path, repo, base_branch, no_preview))
async def cli_budget_prompt(scope: str, projected: float, cap: float) -> bool:
"""Prompt the user to extend the budget cap when it is hit."""
_CONSOLE.print()
_CONSOLE.print(
f"[yellow]Budget cap reached[/]: scope={scope} projected=${projected:.4f} cap=${cap:.4f}"
)
return typer.confirm("Extend cap and proceed?", default=False)
def _static_pricing_seed_fallback() -> list[ModelPrice]:
"""Return seed model prices used when the model_pricing DB table is empty.
Unit: USD per 1,000 tokens. (OpenRouter publishes per-token; we store per-1K to keep
cost arithmetic in a more readable range. ``compute_cost(model, in, out)`` divides
by 1000.)
"""
return [
ModelPrice("anthropic/claude-sonnet-4-6", 0.003, 0.015, 200_000),
ModelPrice("anthropic/claude-haiku-4-5", 0.001, 0.005, 200_000),
ModelPrice("anthropic/claude-opus-4-1", 0.015, 0.075, 200_000),
ModelPrice("deepseek/deepseek-chat", 0.00028, 0.00112, 64_000),
]
async def _load_pricing_from_db(config: Config, db: Database) -> PricingCache:
"""Load pricing from the persisted model_pricing table.
Falls back to the static seed when the table is empty (doctor not yet run).
"""
async with db.session() as s:
rows = list((await s.execute(select(ModelPricingRow))).scalars().all())
cache = PricingCache()
if rows:
cache.set(
[
ModelPrice(
model=r.model,
input_per_1k_usd=r.input_per_1k_usd,
output_per_1k_usd=r.output_per_1k_usd,
context_length=r.context_length,
)
for r in rows
]
)
return cache
cache.set(_static_pricing_seed_fallback())
return cache
def _print_preview(estimate: WorkflowCostEstimate, config: object) -> None:
cfg: Config = config # type: ignore[assignment]
table = Table(title="Cost preview")
table.add_column("Phase")
table.add_column("Persona")
table.add_column("Model")
table.add_column("In/Out tokens", justify="right")
table.add_column("Est. cost", justify="right")
for p in estimate.phases:
cost_str = f"${p.estimated_cost_usd:.4f}"
table.add_row(
p.phase_key,
p.persona_name,
p.model,
f"{p.estimated_input_tokens}/{p.estimated_output_tokens}",
cost_str,
)
_CONSOLE.print(table)
_CONSOLE.print(f"Total estimated: [bold]${estimate.total_usd:.4f}[/]")
_CONSOLE.print(
f"Run cap: [bold]${cfg.budget_run_usd}[/] | Daily cap: [bold]${cfg.budget_daily_usd}[/]"
)
async def _run_async(
workflow_path: Path,
repo: Path,
base_branch: str,
no_preview: bool,
) -> None:
config = load_config()
require_consent(config.data_dir)
template = load_workflow_yaml(workflow_path)
# Locate seed schemas relative to the installed package root
seed_root = Path(__file__).resolve().parents[3] / "docs" / "schemas"
personas_dir = seed_root / "personas"
artifacts_root = seed_root / "artifacts"
personas = load_personas_from_dir(personas_dir)
registry = ArtifactSchemaRegistry(roots=[artifacts_root])
db = Database(config.database_url)
await db.init_schema()
# Crash recovery: mark non-terminal runs from a previous process as failed
# so the active-run uniqueness slot is freed before starting new work.
from ..recovery import sweep_orphan_runs
report = await sweep_orphan_runs(db)
if report.total:
_CONSOLE.print(
f"[yellow]recovery: marked {len(report.failed_runs)} orphan run(s) "
f"and {len(report.failed_phases)} phase(s) as failed[/]"
)
try:
consent_store = PersonaConsentStore(config.data_dir / "persona-consents.json")
bindings = bind_personas(
template,
personas,
BackendAvailability(available_backends=frozenset(Backend)),
consent_store,
)
# Pricing + cost preview — use DB-cached prices; fall back to static seed
pricing = await _load_pricing_from_db(config, db)
if not no_preview:
estimate = estimate_workflow(template, bindings, pricing)
_print_preview(estimate, config)
if not typer.confirm("Proceed?", default=True):
raise typer.Exit(code=0)
budget: BudgetTracker = make_budget_tracker_from_config(
db, config, prompt_callback=cli_budget_prompt
)
await budget.init()
engine = WorkflowEngine(
db=db,
config=config,
persona_pool=personas,
artifact_registry=registry,
consent_store=consent_store,
available_backends=BackendAvailability(available_backends=frozenset(Backend)),
approval_callback=cli_approval_callback,
budget_tracker=budget,
pricing=pricing,
)
engine.install_signal_handlers()
result = await engine.run(
template,
repo_path=repo,
base_branch=base_branch,
)
_CONSOLE.print(f"[bold]{result.state.value}[/] run_id={result.run_id}")
if result.final_report_path:
_CONSOLE.print(f"report: {result.final_report_path}")
if result.error:
_CONSOLE.print(f"[red]error[/]: {result.error}")
raise typer.Exit(code=1)
finally:
await db.dispose()

View File

@@ -0,0 +1,204 @@
"""mydeepagent runs list / show / resume — read-only-ish run history queries."""
from __future__ import annotations
import asyncio
from pathlib import Path
from uuid import UUID
import typer
from rich.console import Console
from rich.table import Table
from sqlalchemy import desc, select
from ..config import load_config
from ..persistence.db import Database
from ..persistence.models import (
ArtifactRow,
RunEventRow,
RunPhaseRow,
RunRow,
)
_CONSOLE = Console()
def runs_list_command(limit: int = 20, state_filter: str | None = None) -> None:
asyncio.run(_runs_list_async(limit, state_filter))
def runs_show_command(run_id: str) -> None:
asyncio.run(_runs_show_async(run_id))
def runs_resume_command(run_id: str) -> None:
asyncio.run(_runs_resume_async(run_id))
async def _runs_list_async(limit: int, state_filter: str | None) -> None:
config = load_config()
db = Database(config.database_url)
await db.init_schema()
try:
async with db.session() as s:
stmt = select(RunRow).order_by(desc(RunRow.created_at)).limit(limit)
if state_filter:
stmt = stmt.where(RunRow.state == state_filter)
rows = (await s.execute(stmt)).scalars().all()
if not rows:
_CONSOLE.print("[dim](no runs)[/]")
return
table = Table(title=f"Recent runs (latest {len(rows)})")
table.add_column("Run ID")
table.add_column("State")
table.add_column("Repo")
table.add_column("Branch")
table.add_column("Created")
table.add_column("Ended")
for r in rows:
table.add_row(
str(r.id)[:8] + "",
r.state,
Path(r.repo_path).name,
r.base_branch,
(r.created_at or "")[:19],
(r.ended_at or "")[:19] if r.ended_at else "",
)
_CONSOLE.print(table)
finally:
await db.dispose()
async def _runs_show_async(run_id: str) -> None:
full_id = await _resolve_run_id(run_id)
config = load_config()
db = Database(config.database_url)
await db.init_schema()
try:
async with db.session() as s:
run = await s.get(RunRow, full_id)
if run is None:
_CONSOLE.print(f"[red]run not found:[/] {run_id}")
raise typer.Exit(code=1)
phases = (
(
await s.execute(
select(RunPhaseRow)
.where(RunPhaseRow.run_id == full_id)
.order_by(RunPhaseRow.seq)
)
)
.scalars()
.all()
)
artifacts = (
(await s.execute(select(ArtifactRow).where(ArtifactRow.run_id == full_id)))
.scalars()
.all()
)
events = (
(
await s.execute(
select(RunEventRow)
.where(RunEventRow.run_id == full_id)
.order_by(RunEventRow.seq)
.limit(50)
)
)
.scalars()
.all()
)
_CONSOLE.print(f"[bold]Run {run.id}[/]")
_CONSOLE.print(f" state: [cyan]{run.state}[/]")
_CONSOLE.print(f" repo: {run.repo_path}@{run.base_branch}")
_CONSOLE.print(f" worktree: {run.worktree_root}")
_CONSOLE.print(f" created: {run.created_at}")
_CONSOLE.print(f" ended: {run.ended_at or ''}")
if run.final_report_path:
_CONSOLE.print(f" report: {run.final_report_path}")
_CONSOLE.print()
_CONSOLE.print("[bold]Phases[/]")
for ph in phases:
_CONSOLE.print(f" - {ph.phase_key:20s} state={ph.state:15s} attempts={ph.attempts}")
if artifacts:
_CONSOLE.print()
_CONSOLE.print("[bold]Artifacts[/]")
for a in artifacts:
_CONSOLE.print(f" - {a.path} (schema={a.schema_id}, valid={a.valid})")
_CONSOLE.print()
_CONSOLE.print(f"[bold]Events (last {len(events)})[/]")
for ev in events:
_CONSOLE.print(f" [{ev.seq:4d}] {ev.ts} {ev.type}")
finally:
await db.dispose()
async def _runs_resume_async(run_id: str) -> None:
"""v0.1.0: resume is not implemented.
Surfaces the run state and hints at next steps. Future v0.2 implementation:
rehydrate the workflow template by template_hash, replay phase loop from the
first non-completed phase using the existing checkpointer.
"""
full_id = await _resolve_run_id(run_id)
config = load_config()
db = Database(config.database_url)
await db.init_schema()
try:
async with db.session() as s:
run = await s.get(RunRow, full_id)
if run is None:
_CONSOLE.print(f"[red]run not found:[/] {run_id}")
raise typer.Exit(code=1)
if run.state in ("completed", "failed", "aborted"):
_CONSOLE.print(
f"[yellow]Run {run.id} is already terminal ({run.state}). "
"Start a fresh run with `mydeepagent run <workflow.yaml>`.[/]"
)
raise typer.Exit(code=1)
_CONSOLE.print(
"[yellow]Resume is not implemented in v0.1.0. The crash-recovery sweep at startup "
"marked this run as failed; relaunch the workflow with `mydeepagent run`.[/]"
)
raise typer.Exit(code=2)
finally:
await db.dispose()
async def _resolve_run_id(prefix_or_full: str) -> str:
"""Accept either a full UUID or a 6+ char prefix and return the canonical full id."""
try:
return str(UUID(prefix_or_full))
except ValueError:
pass
if len(prefix_or_full) < 6:
_CONSOLE.print(
f"[red]ambiguous run id (need full UUID or >=6-char prefix):[/] {prefix_or_full}"
)
raise typer.Exit(code=2)
config = load_config()
db = Database(config.database_url)
await db.init_schema()
try:
async with db.session() as s:
rows = (
(
await s.execute(
select(RunRow.id).where(RunRow.id.like(f"{prefix_or_full}%")).limit(2)
)
)
.scalars()
.all()
)
if not rows:
_CONSOLE.print(f"[red]no run matches prefix:[/] {prefix_or_full}")
raise typer.Exit(code=1)
if len(rows) > 1:
_CONSOLE.print(f"[red]ambiguous prefix matches >1 run:[/] {prefix_or_full}")
raise typer.Exit(code=1)
return rows[0]
finally:
await db.dispose()

View File

@@ -1 +1,179 @@
"""CLI stats command for usage summary. Implemented in Step 12."""
"""mydeepagent stats / costs / budget / pricing — read-only ledger + history queries."""
from __future__ import annotations
import asyncio
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from typing import Any
import typer
from rich.console import Console
from rich.table import Table
from sqlalchemy import func, select
from ..config import load_config
from ..persistence.db import Database
from ..persistence.models import BudgetLedgerRow, LlmCallRow, ModelPricingRow
_CONSOLE = Console()
def stats_command(by: str = "model", since_days: int = 7) -> None:
"""Synchronous CLI wrapper for the async stats query."""
asyncio.run(_stats_async(by, since_days))
async def _stats_async(by: str, since_days: int) -> None:
config = load_config()
db = Database(config.database_url)
await db.init_schema()
try:
since = (datetime.now(UTC) - timedelta(days=since_days)).isoformat(timespec="seconds")
async with db.session() as s:
if by == "model":
rows: Sequence[Any] = (
await s.execute(
select(
LlmCallRow.model,
func.count().label("calls"),
func.sum(LlmCallRow.input_tokens).label("input"),
func.sum(LlmCallRow.output_tokens).label("output"),
func.sum(LlmCallRow.cost_usd_total).label("cost"),
)
.where(LlmCallRow.ts >= since)
.group_by(LlmCallRow.model)
)
).all()
_render_stats_table(
"Stats by model",
rows,
["Model", "Calls", "Input", "Output", "Cost ($)"],
)
elif by == "persona":
rows = (
await s.execute(
select(
LlmCallRow.persona_name,
func.count().label("calls"),
func.sum(LlmCallRow.cost_usd_total).label("cost"),
)
.where(LlmCallRow.ts >= since)
.group_by(LlmCallRow.persona_name)
)
).all()
_render_stats_table(
"Stats by persona",
rows,
["Persona", "Calls", "Cost ($)"],
)
elif by == "day":
rows = (
await s.execute(
select(
func.substr(LlmCallRow.ts, 1, 10).label("day"),
func.count().label("calls"),
func.sum(LlmCallRow.cost_usd_total).label("cost"),
)
.where(LlmCallRow.ts >= since)
.group_by("day")
)
).all()
_render_stats_table(
"Stats by day",
rows,
["Day", "Calls", "Cost ($)"],
)
else:
typer.echo(f"unknown --by option: {by!r}", err=True)
raise typer.Exit(code=2)
finally:
await db.dispose()
def budget_command() -> None:
"""Synchronous CLI wrapper for the async budget ledger query."""
asyncio.run(_budget_async())
async def _budget_async() -> None:
config = load_config()
db = Database(config.database_url)
await db.init_schema()
try:
async with db.session() as s:
rows = list((await s.execute(select(BudgetLedgerRow))).scalars().all())
if not rows:
_CONSOLE.print("[dim](no budget activity yet)[/]")
return
table = Table(title="Budget ledger")
table.add_column("Scope")
table.add_column("Spent ($)", justify="right")
table.add_column("Cap ($)", justify="right")
table.add_column("Remaining ($)", justify="right")
table.add_column("Last update")
for row in rows:
remaining = (
"" if row.cap_usd is None else f"{max(0.0, row.cap_usd - row.spent_usd):.4f}"
)
cap = "" if row.cap_usd is None else f"{row.cap_usd:.4f}"
table.add_row(
row.scope,
f"{row.spent_usd:.4f}",
cap,
remaining,
row.last_updated,
)
_CONSOLE.print(table)
finally:
await db.dispose()
def pricing_command() -> None:
"""Show cached OpenRouter pricing matrix (populated by `doctor`)."""
asyncio.run(_pricing_async())
async def _pricing_async() -> None:
config = load_config()
db = Database(config.database_url)
await db.init_schema()
try:
async with db.session() as s:
rows = list(
(await s.execute(select(ModelPricingRow).order_by(ModelPricingRow.model)))
.scalars()
.all()
)
if not rows:
_CONSOLE.print("[dim](no pricing data — run `mydeepagent doctor` to fetch)[/]")
return
table = Table(title="OpenRouter pricing (per 1K tokens, USD)")
table.add_column("Model")
table.add_column("Input", justify="right")
table.add_column("Output", justify="right")
table.add_column("Context", justify="right")
table.add_column("Fetched")
for r in rows:
table.add_row(
r.model,
f"{r.input_per_1k_usd:.4f}",
f"{r.output_per_1k_usd:.4f}",
str(r.context_length),
(r.fetched_at or "")[:19],
)
_CONSOLE.print(table)
finally:
await db.dispose()
def _render_stats_table(title: str, rows: Sequence[Any], headers: list[str]) -> None:
if not rows:
_CONSOLE.print("[dim](no data for the past period)[/]")
return
table = Table(title=title)
for h in headers:
table.add_column(h)
for row in rows:
table.add_row(*[str(v if v is not None else "") for v in row])
_CONSOLE.print(table)