Files
ai-agent/backend/settings/routes.py
Gabriel Vidal a76cce0c71 feat(users): guest defaults — starting shortcuts + skills shared with guest runs
The owner sets, for every guest at once (users.json guestDefaults):
- promptShortcuts: what a guest's composer starts with. A guest who never
  saved settings gets them as the seed of their ai-agent-settings ui-state.
- sharedSkills: lab/project skills handed to every guest run. The scope
  carries them to the host sidecar, which symlinks them into a generated
  plugin and passes --plugin-dir: they show up as shared:<name>.

GET/PUT /api/guest-defaults (owner), GET /api/guest-skills (the composer's
mentions: shared + granted projects' skills), shareRef/sharedWithGuests on
the skills catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:20:10 +02:00

164 lines
6.7 KiB
Python

"""Settings pages: PWA ui-state, native Claude Code hooks, webhooks and the
mail-trigger allowlist."""
import json
import uuid as uuidlib
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import schemas
from core.config import WORKSPACE
from core.http import _r
from core.state import State
from notifications import store as notify_mod
from settings.ui_state import valid_key
from users.caller import CallerDep
router = APIRouter()
# ── UI state (Settings config + notification bookkeeping) ────────────────────
# The PWA persists its small Zustand `persist` stores here instead of the
# browser's localStorage, so config and the notification feed's seen/new state
# follow the user across browsers/devices. The value is the store's opaque
# serialized JSON blob; the backend just holds the string (see ui_state.py).
class UiStateBody(BaseModel):
value: str
def _ui_key(caller, key: str) -> str:
"""A guest's ui-state lives under their own prefix: their theme, layout
and notification bookkeeping never touch the owner's (or another
guest's). The owner's keys are unchanged."""
if not valid_key(key):
raise HTTPException(400, "bad key")
return key if caller.is_owner else f"u.{caller.id}.{key}"
# The frontend's settings store (`settings.ts`: name + persist `version`).
SETTINGS_KEY, SETTINGS_VERSION = "ai-agent-settings", 3
@router.get("/api/ui-state/{key}", responses=_r(schemas.UiStateValue))
def get_ui_state(deps: State, caller: CallerDep, key: str):
value = deps.ui_state.get(_ui_key(caller, key))
if value is None and not caller.is_owner and key == SETTINGS_KEY:
# A guest who never saved settings starts from the owner's guest
# shortcuts (Settings → Users), not the lab's built-ins: persist
# merges this over the store's defaults, and their first change
# writes the whole store under their own key.
value = json.dumps({"state": {"promptShortcuts": deps.users.defaults()[
"promptShortcuts"]}, "version": SETTINGS_VERSION})
return {"value": value}
@router.put("/api/ui-state/{key}", responses=_r(schemas.OkResponse))
def put_ui_state(deps: State, caller: CallerDep, key: str, body: UiStateBody):
deps.ui_state.set(_ui_key(caller, key), body.value)
return {"ok": True}
@router.delete("/api/ui-state/{key}", responses=_r(schemas.OkResponse))
def delete_ui_state(deps: State, caller: CallerDep, key: str):
deps.ui_state.delete(_ui_key(caller, key))
return {"ok": True}
# Claude Code's own hooks, read out of the workspace's .claude/settings.json.
# Read-only on purpose: writing that file from the container is host-level code
# execution (see the trusted-caller note at the top of this module), so the
# page shows them and points at the repo for edits.
CLAUDE_SETTINGS_REL = ".claude/settings.json"
@router.get("/api/claude-hooks", responses=_r(schemas.ClaudeHooksResponse))
def claude_hooks():
"""The native Claude Code hooks registered in the workspace repo, flattened
to one entry per (event, matcher, command). A missing or unparseable
settings file is an empty list, not an error (standalone has no repo)."""
ap = WORKSPACE / CLAUDE_SETTINGS_REL
try:
data = json.loads(ap.read_text(encoding="utf-8")) or {}
except (OSError, ValueError):
return {"path": CLAUDE_SETTINGS_REL, "exists": ap.exists(),
"entries": []}
entries = []
blocks = data.get("hooks")
for event, matchers in (blocks or {}).items():
for m in (matchers if isinstance(matchers, list) else []):
if not isinstance(m, dict):
continue
for h in (m.get("hooks") or []):
if not isinstance(h, dict):
continue
entries.append({"event": str(event),
"matcher": m.get("matcher") or None,
"type": h.get("type") or "command",
"command": h.get("command") or ""})
return {"path": CLAUDE_SETTINGS_REL, "exists": True, "entries": entries}
# ── first-class notifications: webhooks + notify/ask (see notify.py) ─────────
# This backend is the notification source of truth: the notify-done skill
# POSTs pushes and asks here; they are recorded in /data/notifications.json and
# forwarded to the configured webhooks — normally the Home Assistant webhook
# registered by the `ai_agent` custom integration (integrations/home-assistant/),
# which renders the phone notification and POSTs ask answers back.
class WebhooksBody(BaseModel):
webhooks: list[dict]
class MailTriggerSendersBody(BaseModel):
emails: list[str]
# Kept in the list but not allowed to trigger (the Settings toggle).
disabled: list[str] | None = None
@router.get("/api/webhooks", responses=_r(schemas.WebhooksResponse))
def webhooks_get(deps: State):
return {"webhooks": deps.notify_store.webhooks()}
@router.put("/api/webhooks", responses=_r(schemas.WebhooksResponse))
def webhooks_put(deps: State, body: WebhooksBody):
try:
saved = deps.notify_store.set_webhooks(body.webhooks)
except ValueError as e:
raise HTTPException(400, str(e))
deps.hub.publish({"type": "webhooks"})
return {"webhooks": saved}
@router.get("/api/mail-trigger-senders", responses=_r(schemas.MailTriggerSendersResponse))
def mail_trigger_senders_get(deps: State):
return {"emails": deps.mail_trigger_store.emails(),
"disabled": deps.mail_trigger_store.disabled()}
@router.put("/api/mail-trigger-senders", responses=_r(schemas.MailTriggerSendersResponse))
def mail_trigger_senders_put(deps: State, body: MailTriggerSendersBody):
try:
saved = deps.mail_trigger_store.set_emails(body.emails, body.disabled)
except ValueError as e:
raise HTTPException(400, str(e))
deps.hub.publish({"type": "mail-trigger-senders"})
return saved
@router.post("/api/webhooks/{webhook_id}/test", responses=_r(schemas.NotifyResult))
def webhook_test(deps: State, webhook_id: str):
"""Send a test push through one webhook (regardless of enabled/events)."""
target = next((w for w in deps.notify_store.webhooks()
if w.get("id") == webhook_id), None)
if not target:
raise HTTPException(404, "unknown webhook")
nid = "ntf_" + uuidlib.uuid4().hex[:12]
delivered = notify_mod.forward([target], {
"event": "notify", "id": nid, "title": "ai-agent test",
"message": f"Webhook '{target['name']}' is wired up.",
"type": "success", "url": "https://ai-agent.lab.gabvdl.xyz"})
return {"ok": all(d["ok"] for d in delivered), "id": nid,
"delivered": delivered}