diff --git a/CHANGELOG.md b/CHANGELOG.md index b492d44..ae55dc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/web/router.py b/app/web/router.py index 3a5a3c5..e204d7d 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -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) diff --git a/app/web/templates/install.html b/app/web/templates/install.html index c094177..8716817 100644 --- a/app/web/templates/install.html +++ b/app/web/templates/install.html @@ -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 @@
+ + + {% if banner_html %}
{{ banner_html | safe }}
{% endif %} diff --git a/src/welcome_template.py b/src/welcome_template.py index 72f4ab5..545fe80 100644 --- a/src/welcome_template.py +++ b/src/welcome_template.py @@ -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: diff --git a/tests/test_setup_page_roles.py b/tests/test_setup_page_roles.py new file mode 100644 index 0000000..56fc667 --- /dev/null +++ b/tests/test_setup_page_roles.py @@ -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)