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>
164 lines
6.7 KiB
Python
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}
|