main.py (5146 lines, 106 routes) becomes an assembly only: one package per domain — core, files, settings, dashboards, conversations, diff, notifications, forms, runs, accounts, workers, models, cron, agents, projects, services, goals, memories, plans, templates — each exposing an APIRouter; the flat domain modules move into their package behind a barrel that keeps the old `import conversations` / `import projects` spellings. The shared singletons (store, meta_store, hub, indexer, …) are built once by core.state.build_state() and attached to app.state.ai; routes take them as the `deps: State` dependency and helpers as an explicit `deps: AppState`. conversations/pricing.py carries the per-model rates out of the parser. Verified: route table and OpenAPI byte-identical; 90 read endpoints golden-diffed against the monolith on a copy of the live data (identical); write routes smoke-tested; 66 backend tests pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
107 lines
4.2 KiB
Python
107 lines
4.2 KiB
Python
"""Claude account profiles and their headless login / logout."""
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
import accounts as accounts_mod
|
|
import schemas
|
|
from core.http import _r
|
|
from core.state import AppState, State
|
|
from runs.sidecar_client import _sidecar_call, _sidecar_get
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/api/accounts", responses=_r(schemas.AccountsResponse))
|
|
def list_accounts(health: bool = False):
|
|
"""The Claude account profiles (hard-coded in accounts.py) a session can
|
|
run on. ``health=1`` adds each account's login status from the sidecar
|
|
(``claude auth status`` under that config dir — who, which org, which
|
|
plan); an account the sidecar doesn't know reports ``configured: false``."""
|
|
out = accounts_mod.public()
|
|
if health:
|
|
live = _sidecar_get("/accounts") or {}
|
|
by_id = {a.get("id"): a for a in live.get("accounts") or []}
|
|
for a in out:
|
|
st = by_id.get(a["id"])
|
|
a["status"] = ({
|
|
"configured": True,
|
|
"loggedIn": bool(st.get("loggedIn")),
|
|
"email": st.get("email"),
|
|
"orgName": st.get("orgName"),
|
|
"subscriptionType": st.get("subscriptionType"),
|
|
"authMethod": st.get("authMethod"),
|
|
"error": st.get("error"),
|
|
"pendingLogin": st.get("pendingLogin"),
|
|
} if st else {"configured": False, "loggedIn": False,
|
|
"error": None if live else "sidecar unreachable"})
|
|
return {"accounts": out, "default": accounts_mod.DEFAULT}
|
|
|
|
|
|
# ── account login / logout (headless `claude auth login` via the sidecar) ─────
|
|
class AccountLoginBody(BaseModel):
|
|
email: str | None = None # pre-fills the sign-in page
|
|
|
|
|
|
class AccountLoginCodeBody(BaseModel):
|
|
code: str # the `code#state` the sign-in page shows
|
|
|
|
|
|
def _login_account(account_id: str) -> str:
|
|
a = accounts_mod.valid(account_id)
|
|
if not a:
|
|
raise HTTPException(404, {"message": f"unknown account: {account_id!r}",
|
|
"kind": "unknown"})
|
|
return a
|
|
|
|
|
|
def _account_changed(deps: AppState, account_id: str) -> None:
|
|
deps.hub.publish({"type": "accounts", "account": account_id})
|
|
|
|
|
|
@router.post("/api/accounts/{account_id}/login",
|
|
responses=_r(schemas.PendingLogin))
|
|
def account_login_start(deps: State, account_id: str, body: AccountLoginBody | None = None):
|
|
"""Start a Claude subscription login for the account: the sidecar runs
|
|
``claude auth login`` under the account's config dir and answers the
|
|
sign-in URL. Open it (any device), sign in, and post the code the page
|
|
ends on to ``/login/code``. A new start replaces a pending one."""
|
|
a = _login_account(account_id)
|
|
out = _sidecar_call(deps, f"/accounts/{a}/login",
|
|
{"email": body.email if body else None}, timeout=30)
|
|
_account_changed(deps, a)
|
|
return out
|
|
|
|
|
|
@router.post("/api/accounts/{account_id}/login/code",
|
|
responses=_r(schemas.LoginCodeResult))
|
|
def account_login_code(deps: State, account_id: str, body: AccountLoginCodeBody):
|
|
"""Finish the pending login with the pasted ``code#state``. A malformed or
|
|
foreign code is a 400 and the login stays pending (paste again); a failed
|
|
exchange ends it (start again)."""
|
|
a = _login_account(account_id)
|
|
out = _sidecar_call(deps, f"/accounts/{a}/login/code", {"code": body.code},
|
|
timeout=60)
|
|
_account_changed(deps, a)
|
|
return out
|
|
|
|
|
|
@router.delete("/api/accounts/{account_id}/login")
|
|
def account_login_cancel(deps: State, account_id: str):
|
|
a = _login_account(account_id)
|
|
out = _sidecar_call(deps, f"/accounts/{a}/login", method="DELETE")
|
|
_account_changed(deps, a)
|
|
return out
|
|
|
|
|
|
@router.post("/api/accounts/{account_id}/logout",
|
|
responses=_r(schemas.LoginCodeResult))
|
|
def account_logout(deps: State, account_id: str):
|
|
"""``claude auth logout`` for the account — new runs on it fail with an
|
|
``auth`` error until it logs back in."""
|
|
a = _login_account(account_id)
|
|
out = _sidecar_call(deps, f"/accounts/{a}/logout", {}, timeout=40)
|
|
_account_changed(deps, a)
|
|
return out
|