Merge branch 'codex-on-main'
This commit is contained in:
@@ -12,6 +12,61 @@ from src.auth_helpers import get_current_user
|
||||
|
||||
MAX_NAME_LEN = 100
|
||||
DEFAULT_SCOPES = "chat"
|
||||
ALLOWED_SCOPES = {
|
||||
"chat",
|
||||
"todos:read",
|
||||
"todos:write",
|
||||
"documents:read",
|
||||
"documents:write",
|
||||
"email:read",
|
||||
"email:draft",
|
||||
"email:send",
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"memory:read",
|
||||
"memory:write",
|
||||
}
|
||||
TOKEN_PROFILES = {
|
||||
"chat": ["chat"],
|
||||
"codex_todos": ["todos:read", "todos:write"],
|
||||
"codex_email_drafts": ["email:read", "email:draft", "documents:read", "documents:write"],
|
||||
}
|
||||
|
||||
|
||||
def _normalize_scopes(scopes: str | list[str] | None = None, profile: str | None = None) -> list[str]:
|
||||
profile = profile if isinstance(profile, str) else None
|
||||
profile_key = (profile or "").strip()
|
||||
if profile_key:
|
||||
if profile_key not in TOKEN_PROFILES:
|
||||
raise HTTPException(400, "Unknown token profile")
|
||||
requested = list(TOKEN_PROFILES[profile_key])
|
||||
elif isinstance(scopes, list):
|
||||
requested = [str(s).strip() for s in scopes if str(s).strip()]
|
||||
elif isinstance(scopes, str) and scopes:
|
||||
requested = [s.strip() for s in scopes.replace(" ", ",").split(",") if s.strip()]
|
||||
else:
|
||||
requested = [DEFAULT_SCOPES]
|
||||
|
||||
normalized = []
|
||||
for scope in requested:
|
||||
if scope not in ALLOWED_SCOPES:
|
||||
raise HTTPException(400, f"Unknown token scope: {scope}")
|
||||
if scope not in normalized:
|
||||
normalized.append(scope)
|
||||
|
||||
def ensure_before(write_scope: str, read_scope: str):
|
||||
if write_scope not in normalized or read_scope in normalized:
|
||||
return
|
||||
idx = normalized.index(write_scope)
|
||||
normalized.insert(idx, read_scope)
|
||||
|
||||
ensure_before("todos:write", "todos:read")
|
||||
ensure_before("documents:write", "documents:read")
|
||||
ensure_before("calendar:write", "calendar:read")
|
||||
ensure_before("memory:write", "memory:read")
|
||||
ensure_before("email:draft", "email:read")
|
||||
|
||||
return normalized or [DEFAULT_SCOPES]
|
||||
|
||||
|
||||
def setup_api_token_routes() -> APIRouter:
|
||||
@@ -45,13 +100,28 @@ def setup_api_token_routes() -> APIRouter:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@router.get("/tokens/profiles")
|
||||
def token_profiles(request: Request):
|
||||
require_admin(request)
|
||||
return {
|
||||
"profiles": TOKEN_PROFILES,
|
||||
"allowed_scopes": sorted(ALLOWED_SCOPES),
|
||||
}
|
||||
|
||||
@router.post("/tokens")
|
||||
def create_token(request: Request, name: str = Form("")):
|
||||
def create_token(
|
||||
request: Request,
|
||||
name: str = Form(""),
|
||||
scopes: str = Form(None),
|
||||
profile: str = Form(None),
|
||||
):
|
||||
require_admin(request)
|
||||
name = name.strip()[:MAX_NAME_LEN]
|
||||
if not name:
|
||||
raise HTTPException(400, "Token name is required")
|
||||
owner = get_current_user(request)
|
||||
scope_list = _normalize_scopes(scopes, profile)
|
||||
scopes_value = ",".join(scope_list)
|
||||
|
||||
raw_token = "ody_" + secrets.token_urlsafe(32)
|
||||
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
|
||||
@@ -64,7 +134,7 @@ def setup_api_token_routes() -> APIRouter:
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=raw_token[:8],
|
||||
scopes=DEFAULT_SCOPES,
|
||||
scopes=scopes_value,
|
||||
is_active=True,
|
||||
))
|
||||
_invalidate_cache(request)
|
||||
@@ -75,9 +145,36 @@ def setup_api_token_routes() -> APIRouter:
|
||||
"owner": owner,
|
||||
"token": raw_token,
|
||||
"token_prefix": raw_token[:8],
|
||||
"scopes": DEFAULT_SCOPES.split(","),
|
||||
"scopes": scope_list,
|
||||
}
|
||||
|
||||
@router.patch("/tokens/{token_id}")
|
||||
async def update_token(request: Request, token_id: str):
|
||||
require_admin(request)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
scope_list = _normalize_scopes(payload.get("scopes"))
|
||||
scopes_value = ",".join(scope_list)
|
||||
with get_db_session() as db:
|
||||
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
|
||||
if not token:
|
||||
raise HTTPException(404, "Token not found")
|
||||
if isinstance(payload.get("name"), str) and payload["name"].strip():
|
||||
token.name = payload["name"].strip()[:MAX_NAME_LEN]
|
||||
token.scopes = scopes_value
|
||||
db.add(token)
|
||||
response = {
|
||||
"id": token_id,
|
||||
"name": getattr(token, "name", ""),
|
||||
"owner": getattr(token, "owner", None),
|
||||
"token_prefix": getattr(token, "token_prefix", ""),
|
||||
"scopes": scope_list,
|
||||
}
|
||||
_invalidate_cache(request)
|
||||
return response
|
||||
|
||||
@router.delete("/tokens/{token_id}")
|
||||
def delete_token(request: Request, token_id: str):
|
||||
require_admin(request)
|
||||
|
||||
407
routes/codex_routes.py
Normal file
407
routes/codex_routes.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""Codex integration routes.
|
||||
|
||||
These are small HTTP surfaces intended for the Codex plugin/MCP bridge. They
|
||||
reuse existing Odysseus helpers and enforce API-token scopes before touching
|
||||
user data.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from src.auth_helpers import require_user
|
||||
from src.tool_implementations import do_manage_notes
|
||||
|
||||
|
||||
TODO_READ_SCOPES = {"todos:read", "todos:write"}
|
||||
TODO_WRITE_SCOPES = {"todos:write"}
|
||||
EMAIL_READ_SCOPES = {"email:read", "email:draft", "email:send"}
|
||||
EMAIL_DRAFT_SCOPES = {"email:draft", "email:send"}
|
||||
EMAIL_SEND_SCOPES = {"email:send"}
|
||||
MEMORY_READ_SCOPES = {"memory:read", "memory:write"}
|
||||
MEMORY_WRITE_SCOPES = {"memory:write"}
|
||||
CALENDAR_READ_SCOPES = {"calendar:read", "calendar:write"}
|
||||
CALENDAR_WRITE_SCOPES = {"calendar:write"}
|
||||
DOCS_READ_SCOPES = {"documents:read", "documents:write"}
|
||||
DOCS_WRITE_SCOPES = {"documents:write"}
|
||||
WRITE_ACTIONS = {"add", "create", "new", "save", "remind", "update", "delete", "toggle_item", "remove", "remove_item"}
|
||||
|
||||
|
||||
async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
|
||||
"""Run an existing route handler with request.state.current_user temporarily
|
||||
set to ``owner`` so its internal get_current_user/require_user calls see
|
||||
the scope-gated owner (not the "api" pseudo-user the bearer middleware sets).
|
||||
Restores the original value when done. Works for sync and async handlers."""
|
||||
orig = getattr(request.state, "current_user", None)
|
||||
request.state.current_user = owner
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
return result
|
||||
finally:
|
||||
request.state.current_user = orig
|
||||
|
||||
|
||||
def _scope_owner(request: Request, allowed: set[str]) -> str:
|
||||
"""Return the data owner if the caller is allowed for this Codex action."""
|
||||
if getattr(request.state, "api_token", False):
|
||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
if not scopes.intersection(allowed):
|
||||
required = " or ".join(sorted(allowed))
|
||||
raise HTTPException(403, f"API token missing required scope: {required}")
|
||||
owner = getattr(request.state, "api_token_owner", None)
|
||||
if not owner:
|
||||
raise HTTPException(403, "API token has no owner")
|
||||
return owner
|
||||
return require_user(request)
|
||||
|
||||
|
||||
def _find_endpoint(router: APIRouter | None, method: str, path: str):
|
||||
if router is None:
|
||||
return None
|
||||
for route in getattr(router, "routes", []):
|
||||
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
return None
|
||||
|
||||
|
||||
def setup_codex_routes(
|
||||
email_router: APIRouter | None = None,
|
||||
memory_router: APIRouter | None = None,
|
||||
calendar_router: APIRouter | None = None,
|
||||
document_router: APIRouter | None = None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/codex", tags=["codex"])
|
||||
email_list_endpoint = _find_endpoint(email_router, "GET", "/api/email/list")
|
||||
email_read_endpoint = _find_endpoint(email_router, "GET", "/api/email/read/{uid}")
|
||||
email_send_endpoint = _find_endpoint(email_router, "POST", "/api/email/send")
|
||||
email_draft_endpoint = _find_endpoint(email_router, "POST", "/api/email/draft")
|
||||
memory_list_endpoint = _find_endpoint(memory_router, "GET", "/api/memory")
|
||||
memory_add_endpoint = _find_endpoint(memory_router, "POST", "/api/memory/add")
|
||||
calendar_list_events = _find_endpoint(calendar_router, "GET", "/api/calendar/events")
|
||||
calendar_create_event = _find_endpoint(calendar_router, "POST", "/api/calendar/events")
|
||||
documents_library_endpoint = _find_endpoint(document_router, "GET", "/api/documents/library")
|
||||
documents_get_endpoint = _find_endpoint(document_router, "GET", "/api/document/{doc_id}")
|
||||
documents_create_endpoint = _find_endpoint(document_router, "POST", "/api/document")
|
||||
|
||||
@router.get("/capabilities")
|
||||
def capabilities(request: Request):
|
||||
token_scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
has_token = bool(getattr(request.state, "api_token", False))
|
||||
def scoped(allowed):
|
||||
return bool(token_scopes.intersection(allowed)) if has_token else True
|
||||
return {
|
||||
"integration": "codex",
|
||||
"token_scopes": sorted(token_scopes),
|
||||
"tools": {
|
||||
"todos": {
|
||||
"read": scoped(TODO_READ_SCOPES),
|
||||
"write": scoped(TODO_WRITE_SCOPES),
|
||||
"actions": ["list", "add", "update", "delete", "toggle_item"],
|
||||
},
|
||||
"email": {
|
||||
"read": scoped(EMAIL_READ_SCOPES),
|
||||
"draft": scoped(EMAIL_DRAFT_SCOPES),
|
||||
"send": scoped(EMAIL_SEND_SCOPES),
|
||||
"actions": ["list", "read", "draft", "send"],
|
||||
},
|
||||
"memory": {
|
||||
"read": scoped(MEMORY_READ_SCOPES),
|
||||
"write": scoped(MEMORY_WRITE_SCOPES),
|
||||
"actions": ["list", "add", "delete"],
|
||||
"available": memory_list_endpoint is not None,
|
||||
},
|
||||
"calendar": {
|
||||
"read": scoped(CALENDAR_READ_SCOPES),
|
||||
"write": scoped(CALENDAR_WRITE_SCOPES),
|
||||
"actions": ["list_events", "create_event", "delete_event"],
|
||||
"available": calendar_list_events is not None,
|
||||
},
|
||||
"documents": {
|
||||
"read": scoped(DOCS_READ_SCOPES),
|
||||
"write": scoped(DOCS_WRITE_SCOPES),
|
||||
"actions": ["library", "read", "create", "delete"],
|
||||
"available": documents_library_endpoint is not None,
|
||||
},
|
||||
},
|
||||
"safety": {
|
||||
"email_send_requires_confirmation": True,
|
||||
"destructive_actions_should_confirm": True,
|
||||
},
|
||||
}
|
||||
|
||||
@router.get("/plugin.zip")
|
||||
def plugin_zip(request: Request):
|
||||
require_user(request)
|
||||
root = Path(__file__).resolve().parent.parent / "integrations" / "codex"
|
||||
if not root.exists():
|
||||
raise HTTPException(404, "Codex plugin bundle not found")
|
||||
buf = BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for path in sorted(root.rglob("*")):
|
||||
if path.is_dir() or "__pycache__" in path.parts or path.suffix == ".pyc":
|
||||
continue
|
||||
zf.write(path, Path("odysseus") / path.relative_to(root))
|
||||
buf.seek(0)
|
||||
headers = {"Content-Disposition": 'attachment; filename="odysseus-codex-plugin.zip"'}
|
||||
return StreamingResponse(buf, media_type="application/zip", headers=headers)
|
||||
|
||||
@router.get("/todos")
|
||||
async def list_todos(request: Request, archived: bool = False, label: str | None = None):
|
||||
owner = _scope_owner(request, TODO_READ_SCOPES)
|
||||
args: dict[str, Any] = {"action": "list", "archived": archived}
|
||||
if label:
|
||||
args["label"] = label
|
||||
return await do_manage_notes(json.dumps(args), owner=owner)
|
||||
|
||||
@router.post("/todos")
|
||||
async def manage_todos(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
action = str(body.get("action") or "add").replace("-", "_").strip().lower()
|
||||
allowed = TODO_WRITE_SCOPES if action in WRITE_ACTIONS else TODO_READ_SCOPES
|
||||
owner = _scope_owner(request, allowed)
|
||||
args = dict(body)
|
||||
args["action"] = action
|
||||
return await do_manage_notes(json.dumps(args), owner=owner)
|
||||
|
||||
@router.get("/emails")
|
||||
async def list_emails(
|
||||
request: Request,
|
||||
folder: str = "INBOX",
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
filter: str = "all",
|
||||
from_addr: str | None = None,
|
||||
account_id: str | None = None,
|
||||
has_attachments: int = 0,
|
||||
):
|
||||
owner = _scope_owner(request, EMAIL_READ_SCOPES)
|
||||
if email_list_endpoint is None:
|
||||
raise HTTPException(503, "Email integration is not available")
|
||||
limit = max(1, min(int(limit or 10), 50))
|
||||
offset = max(0, int(offset or 0))
|
||||
if account_id:
|
||||
from routes.email_helpers import _assert_owns_account
|
||||
|
||||
_assert_owns_account(account_id, owner)
|
||||
return await email_list_endpoint(
|
||||
folder=folder,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
filter=filter,
|
||||
from_addr=from_addr,
|
||||
account_id=account_id,
|
||||
has_attachments=has_attachments,
|
||||
cache_bust=None,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
@router.get("/emails/{uid}")
|
||||
async def read_email(
|
||||
request: Request,
|
||||
uid: str,
|
||||
folder: str = "INBOX",
|
||||
account_id: str | None = None,
|
||||
mark_seen: bool = False,
|
||||
):
|
||||
owner = _scope_owner(request, EMAIL_READ_SCOPES)
|
||||
if email_read_endpoint is None:
|
||||
raise HTTPException(503, "Email integration is not available")
|
||||
if account_id:
|
||||
from routes.email_helpers import _assert_owns_account
|
||||
|
||||
_assert_owns_account(account_id, owner)
|
||||
return await email_read_endpoint(
|
||||
uid=uid,
|
||||
folder=folder,
|
||||
account_id=account_id,
|
||||
mark_seen=mark_seen,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
# ── Email draft + send ────────────────────────────────────────────────
|
||||
# Both handlers in routes/email_routes.py already accept `owner=` via
|
||||
# FastAPI Depends, so we call them directly without patching state.
|
||||
|
||||
@router.post("/emails/draft")
|
||||
async def codex_email_draft(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
owner = _scope_owner(request, EMAIL_DRAFT_SCOPES)
|
||||
if email_draft_endpoint is None:
|
||||
raise HTTPException(503, "Email integration is not available")
|
||||
from routes.email_routes import SendEmailRequest
|
||||
|
||||
try:
|
||||
req = SendEmailRequest(**body)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Invalid draft payload: {exc}")
|
||||
return await email_draft_endpoint(req=req, owner=owner)
|
||||
|
||||
@router.post("/emails/send")
|
||||
async def codex_email_send(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
owner = _scope_owner(request, EMAIL_SEND_SCOPES)
|
||||
if email_send_endpoint is None:
|
||||
raise HTTPException(503, "Email integration is not available")
|
||||
from routes.email_routes import SendEmailRequest
|
||||
|
||||
try:
|
||||
req = SendEmailRequest(**body)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Invalid send payload: {exc}")
|
||||
return await email_send_endpoint(req=req, background_tasks=BackgroundTasks(), owner=owner)
|
||||
|
||||
# ── Memory ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/memory")
|
||||
async def codex_memory_list(request: Request):
|
||||
owner = _scope_owner(request, MEMORY_READ_SCOPES)
|
||||
if memory_list_endpoint is None:
|
||||
raise HTTPException(503, "Memory integration is not available")
|
||||
return await _as_owner(request, owner, memory_list_endpoint, request)
|
||||
|
||||
@router.post("/memory")
|
||||
async def codex_memory_add(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
owner = _scope_owner(request, MEMORY_WRITE_SCOPES)
|
||||
if memory_add_endpoint is None:
|
||||
raise HTTPException(503, "Memory integration is not available")
|
||||
from src.request_models import MemoryAddRequest
|
||||
|
||||
try:
|
||||
memory_data = MemoryAddRequest(
|
||||
text=str(body.get("text") or "").strip(),
|
||||
category=body.get("category", "fact"),
|
||||
source=body.get("source", "user"),
|
||||
session_id=body.get("session_id"),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Invalid memory payload: {exc}")
|
||||
if not memory_data.text:
|
||||
raise HTTPException(400, "Empty memory text")
|
||||
return await _as_owner(request, owner, memory_add_endpoint, request, memory_data)
|
||||
|
||||
# ── Calendar ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/calendar/events")
|
||||
async def codex_calendar_list(request: Request, start: str, end: str, calendar: str = ""):
|
||||
owner = _scope_owner(request, CALENDAR_READ_SCOPES)
|
||||
if calendar_list_events is None:
|
||||
raise HTTPException(503, "Calendar integration is not available")
|
||||
return await _as_owner(request, owner, calendar_list_events, request, start, end, calendar)
|
||||
|
||||
@router.post("/calendar/events")
|
||||
async def codex_calendar_create(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
owner = _scope_owner(request, CALENDAR_WRITE_SCOPES)
|
||||
if calendar_create_event is None:
|
||||
raise HTTPException(503, "Calendar integration is not available")
|
||||
from routes.calendar_routes import EventCreate
|
||||
|
||||
try:
|
||||
data = EventCreate(**body)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Invalid event payload: {exc}")
|
||||
return await _as_owner(request, owner, calendar_create_event, request, data)
|
||||
|
||||
# ── Documents ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/documents")
|
||||
async def codex_documents_library(
|
||||
request: Request,
|
||||
search: str | None = None,
|
||||
language: str | None = None,
|
||||
sort: str = "recent",
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
archived: bool = False,
|
||||
):
|
||||
owner = _scope_owner(request, DOCS_READ_SCOPES)
|
||||
if documents_library_endpoint is None:
|
||||
raise HTTPException(503, "Documents integration is not available")
|
||||
return await _as_owner(
|
||||
request, owner, documents_library_endpoint,
|
||||
request, search, language, sort, offset, limit, archived,
|
||||
)
|
||||
|
||||
@router.get("/documents/{doc_id}")
|
||||
async def codex_documents_get(request: Request, doc_id: str):
|
||||
owner = _scope_owner(request, DOCS_READ_SCOPES)
|
||||
if documents_get_endpoint is None:
|
||||
raise HTTPException(503, "Documents integration is not available")
|
||||
return await _as_owner(request, owner, documents_get_endpoint, request, doc_id)
|
||||
|
||||
# ── DELETE endpoints so agents can clean up after themselves ──────────
|
||||
|
||||
memory_delete_endpoint = _find_endpoint(memory_router, "DELETE", "/api/memory/{memory_id}")
|
||||
calendar_delete_event = _find_endpoint(calendar_router, "DELETE", "/api/calendar/events/{uid}")
|
||||
documents_delete_endpoint = _find_endpoint(document_router, "DELETE", "/api/document/{doc_id}")
|
||||
|
||||
@router.delete("/memory/{memory_id}")
|
||||
async def codex_memory_delete(request: Request, memory_id: str):
|
||||
owner = _scope_owner(request, MEMORY_WRITE_SCOPES)
|
||||
if memory_delete_endpoint is None:
|
||||
raise HTTPException(503, "Memory delete not available")
|
||||
return await _as_owner(request, owner, memory_delete_endpoint, request, memory_id)
|
||||
|
||||
@router.delete("/calendar/events/{uid}")
|
||||
async def codex_calendar_delete(request: Request, uid: str):
|
||||
owner = _scope_owner(request, CALENDAR_WRITE_SCOPES)
|
||||
if calendar_delete_event is None:
|
||||
raise HTTPException(503, "Calendar delete not available")
|
||||
return await _as_owner(request, owner, calendar_delete_event, request, uid)
|
||||
|
||||
@router.delete("/documents/{doc_id}")
|
||||
async def codex_documents_delete(request: Request, doc_id: str):
|
||||
owner = _scope_owner(request, DOCS_WRITE_SCOPES)
|
||||
if documents_delete_endpoint is None:
|
||||
raise HTTPException(503, "Documents delete not available")
|
||||
return await _as_owner(request, owner, documents_delete_endpoint, request, doc_id)
|
||||
|
||||
@router.post("/documents")
|
||||
async def codex_documents_create(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
owner = _scope_owner(request, DOCS_WRITE_SCOPES)
|
||||
if documents_create_endpoint is None:
|
||||
raise HTTPException(503, "Documents integration is not available")
|
||||
from routes.document_routes import DocumentCreate
|
||||
|
||||
try:
|
||||
req = DocumentCreate(**body)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Invalid document payload: {exc}")
|
||||
return await _as_owner(request, owner, documents_create_endpoint, request, req)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def setup_claude_routes() -> APIRouter:
|
||||
"""Serve the Claude Code skill bundle.
|
||||
|
||||
Claude Code uses the same scope-gated `/api/codex/*` endpoints at runtime;
|
||||
this router only exists to deliver the skill zip via `/api/claude/plugin.zip`
|
||||
so the user-facing setup commands stay in the Claude namespace.
|
||||
"""
|
||||
router = APIRouter(prefix="/api/claude", tags=["claude"])
|
||||
|
||||
@router.get("/plugin.zip")
|
||||
def plugin_zip(request: Request):
|
||||
require_user(request)
|
||||
# Only ship the skills/ subtree so extracting at ~/.claude/ doesn't dump
|
||||
# README.md or other bundle metadata into the user's claude config dir.
|
||||
skills_root = Path(__file__).resolve().parent.parent / "integrations" / "claude" / "skills"
|
||||
if not skills_root.exists():
|
||||
raise HTTPException(404, "Claude skill bundle not found")
|
||||
bundle_root = skills_root.parent
|
||||
buf = BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for path in sorted(skills_root.rglob("*")):
|
||||
if path.is_dir() or "__pycache__" in path.parts or path.suffix == ".pyc":
|
||||
continue
|
||||
zf.write(path, path.relative_to(bundle_root))
|
||||
buf.seek(0)
|
||||
headers = {"Content-Disposition": 'attachment; filename="odysseus-claude-skill.zip"'}
|
||||
return StreamingResponse(buf, media_type="application/zip", headers=headers)
|
||||
|
||||
return router
|
||||
@@ -446,7 +446,6 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
"summarize_emails": ("email_summaries",),
|
||||
"draft_email_replies": ("email_ai_replies",),
|
||||
"extract_email_events": ("email_calendar_extractions",),
|
||||
"mark_email_boundaries": ("email_boundaries",),
|
||||
"learn_sender_signatures": ("sender_signatures",),
|
||||
"check_email_urgency": ("email_tags", "email_urgency_alerts"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user