feat(setup): /setup?role=analyst|admin branching with role tiles

This commit is contained in:
ZdenekSrotyr 2026-05-04 17:28:47 +02:00
parent 54f83c281c
commit f731ee7897
5 changed files with 173 additions and 6 deletions

View file

@ -10,6 +10,16 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased]
### Added
- **`/setup?role=analyst|admin` query-param branching**: the setup page now
renders two role tiles (Analyst workspace / Admin CLI) at the top, with
the matching tile styled as active. `?role=analyst` short-circuits the
bash bootstrap to the trimmed analyst-workspace flow (no marketplace,
no plugins). Default is `admin` — unspecified or invalid values fall
back to the existing admin layout, so any caller that doesn't pass
`?role=` keeps the byte-identical pre-Task-4 page. `/install` still
302-redirects to `/setup`.
## [0.32.0] — 2026-05-04
Closes #160. Headline fix: `da query --remote` now resolves

View file

@ -7,10 +7,10 @@ import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Optional
from typing import Literal, Optional
from urllib.parse import quote
from fastapi import APIRouter, Depends, Request, HTTPException
from fastapi import APIRouter, Depends, Query, Request, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
import duckdb
@ -717,11 +717,19 @@ async def activity_center(
@router.get("/setup", response_class=HTMLResponse)
async def setup_page(
request: Request,
role: Literal["analyst", "admin"] = Query(default="admin"),
user: Optional[dict] = Depends(get_optional_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Setup instructions for the local agent (CLI + Claude Code).
The `role` query param picks the layout:
- `admin` (default) full marketplace + skills + diagnose flow,
byte-identical to the pre-Task-4 page for any caller that doesn't
pass `?role=`.
- `analyst` trimmed workspace-bootstrap flow rendered by
`_resolve_analyst_lines` (no marketplace, no plugins).
When an admin override is saved, the override replaces the auto-generated
setup_instructions output everywhere (both the /setup page display and the
dashboard clipboard CTA). When no override is set, the live default from
@ -734,6 +742,9 @@ async def setup_page(
base_url = str(request.base_url).rstrip("/")
# Determine the script text: override (Jinja2-rendered) or live default.
# The override is role-agnostic by design — admins who set an override are
# opting into the exact text they wrote, regardless of which tile the
# caller picked. Only the live default branches on `role`.
row = WelcomeTemplateRepository(conn).get()
override_content = row.get("content")
if override_content:
@ -748,9 +759,13 @@ async def setup_page(
setup_script_text = _sanitize_banner_html(template.render(**ctx_vars))
except (TemplateError, Exception) as exc:
logger.warning("setup_page: override render failed (%s); falling back to default", exc)
setup_script_text = compute_default_agent_prompt(conn, user=user, server_url=base_url)
setup_script_text = compute_default_agent_prompt(
conn, user=user, server_url=base_url, role=role,
)
else:
setup_script_text = compute_default_agent_prompt(conn, user=user, server_url=base_url)
setup_script_text = compute_default_agent_prompt(
conn, user=user, server_url=base_url, role=role,
)
# Split for the legacy setup_instructions_lines list variable that the
# Jinja2 partial (_claude_setup_instructions.jinja) uses.
@ -766,6 +781,7 @@ async def setup_page(
# Override both variables so the partial and the JS array stay in sync.
setup_instructions_lines=setup_instructions_lines,
setup_script_text=setup_script_text,
role=role,
)
return templates.TemplateResponse(request, "install.html", ctx)

View file

@ -629,6 +629,46 @@
.manual-body { padding: 16px 18px 18px; }
}
/* ── Role tiles (Analyst vs Admin) ── */
.role-tiles {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.role-tile {
flex: 1;
padding: 16px 18px;
border: 2px solid var(--border, #e1e4e8);
border-radius: 10px;
background: var(--surface);
text-decoration: none;
color: inherit;
transition: border-color 0.15s ease, background 0.15s ease;
display: block;
}
.role-tile:hover {
border-color: var(--primary, #0073D1);
}
.role-tile.is-active {
border-color: var(--primary, #0073D1);
background: var(--primary-light, rgba(0, 115, 209, 0.08));
}
.role-tile h3 {
margin: 0 0 4px 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.role-tile p {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
@media (max-width: 720px) {
.role-tiles { flex-direction: column; }
}
/* ── Admin-configured banner (above setup commands) ── */
.setup-banner {
background: var(--background, #f6f7f9);
@ -663,6 +703,22 @@
<main class="main">
<!-- ═══════════════ ROLE TILES ═══════════════ -->
<nav class="role-tiles" aria-label="Choose setup role">
<a href="/setup?role=analyst"
class="role-tile{% if role == 'analyst' %} is-active{% endif %}"
{% if role == 'analyst' %}aria-current="page"{% endif %}>
<h3>Analyst workspace</h3>
<p>Bootstrap a workspace folder with CLAUDE.md, hooks, and synced data.</p>
</a>
<a href="/setup?role=admin"
class="role-tile{% if role != 'analyst' %} is-active{% endif %}"
{% if role != 'analyst' %}aria-current="page"{% endif %}>
<h3>Admin CLI</h3>
<p>Install the CLI, register the marketplace, set up admin tooling.</p>
</a>
</nav>
<!-- ═══════════════ ADMIN BANNER (optional) ═══════════════ -->
{% if banner_html %}<div class="setup-banner">{{ banner_html | safe }}</div>{% endif %}

View file

@ -24,7 +24,7 @@ import os
import re
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any
from typing import Any, Literal
from urllib.parse import urlparse
import duckdb
@ -130,6 +130,7 @@ def compute_default_agent_prompt(
*,
user: dict[str, Any] | None,
server_url: str,
role: Literal["analyst", "admin"] = "admin",
) -> str:
"""Return the live default setup script from setup_instructions.resolve_lines().
@ -140,6 +141,10 @@ def compute_default_agent_prompt(
``conn`` and ``user`` are forwarded to resolve the RBAC-filtered plugin
install list (anonymous visitors / no conn get the no-marketplace layout).
``server_url`` is used to derive the server host for the marketplace block.
``role`` selects the layout: ``"admin"`` (default) keeps the existing
full bootstrap, ``"analyst"`` short-circuits to the trimmed analyst
workspace flow (no marketplace, plugins forced empty).
"""
try:
from app.web.setup_instructions import resolve_lines
@ -148,8 +153,12 @@ def compute_default_agent_prompt(
_wheel = _find_wheel()
_wheel_filename = _wheel.name if _wheel else "agnes.whl"
# Analyst flow has no marketplace concept — skip the RBAC plugin
# resolution entirely so the analyst tile renders the same lines for
# everyone (and so resolve_lines's analyst short-circuit fires
# regardless of whether the caller has plugin grants).
plugin_install_names: list[str] = []
if user and conn is not None:
if role == "admin" and user and conn is not None:
try:
from src import marketplace_filter
plugin_install_names = [
@ -179,6 +188,7 @@ def compute_default_agent_prompt(
self_signed_tls=self_signed_tls,
server_host=server_host,
ca_pem=ca_pem,
role=role,
)
return "\n".join(lines)
except Exception:

View file

@ -0,0 +1,75 @@
"""Tests for /setup role query-param branching.
Task 4 wires `?role=analyst|admin` through the /setup route handler so the
template can render two role tiles and the renderer can pick the right
layout (admin = full marketplace/skills/diagnose flow; analyst = trimmed
workspace-bootstrap flow). Default is `admin` to preserve existing behavior.
"""
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path, monkeypatch):
"""TestClient against a freshly-built FastAPI app rooted at tmp_path.
Mirrors the `web_client` fixture in tests/test_web_ui.py we re-create
the app so the DuckDB singleton picks up the per-test DATA_DIR rather
than leaking state across tests on the same xdist worker.
"""
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-min-32-characters!!")
(tmp_path / "state").mkdir()
(tmp_path / "analytics").mkdir()
(tmp_path / "extracts").mkdir()
from src.db import close_system_db
close_system_db()
from app.main import create_app
app = create_app()
yield TestClient(app)
close_system_db()
def test_setup_page_default_role_is_admin(client):
"""No `role` query param → admin layout (default, preserves existing flow)."""
resp = client.get("/setup", follow_redirects=True)
assert resp.status_code == 200
text = resp.text
# Both tiles present in markup; admin tile is the active one.
assert "role=analyst" in text
assert "role=admin" in text or 'href="/setup"' in text
# Active state lives on the admin tile when role=admin (default).
# Asserting the tile labels are both rendered keeps the assertion
# robust against future styling tweaks.
assert "Analyst workspace" in text
assert "Admin CLI" in text
def test_setup_page_analyst_role(client):
"""`?role=analyst` → analyst tile is the active one."""
resp = client.get("/setup?role=analyst", follow_redirects=True)
assert resp.status_code == 200
text = resp.text
assert "Analyst workspace" in text
assert "Admin CLI" in text
# The page must reflect the analyst selection somewhere — either via
# the active-state CSS class or the `role=analyst` link being rendered.
assert "role=analyst" in text
def test_install_redirects_to_setup(client):
"""`/install` legacy path keeps redirecting to `/setup` (302/307)."""
resp = client.get("/install", follow_redirects=False)
assert resp.status_code in (302, 307)
assert "/setup" in resp.headers["location"]
def test_setup_page_invalid_role_falls_back(client):
"""Invalid role values must NOT 500 — either FastAPI's Literal
validation rejects with 422, or the route quietly falls back to admin.
Both are acceptable; what's not acceptable is an unhandled exception.
"""
resp = client.get("/setup?role=hacker", follow_redirects=True)
assert resp.status_code in (200, 422)