feat(setup): /setup?role=analyst|admin branching with role tiles
This commit is contained in:
parent
54f83c281c
commit
f731ee7897
5 changed files with 173 additions and 6 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -10,6 +10,16 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.32.0] — 2026-05-04
|
||||||
|
|
||||||
Closes #160. Headline fix: `da query --remote` now resolves
|
Closes #160. Headline fix: `da query --remote` now resolves
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Literal, Optional
|
||||||
from urllib.parse import quote
|
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.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
import duckdb
|
import duckdb
|
||||||
|
|
@ -717,11 +717,19 @@ async def activity_center(
|
||||||
@router.get("/setup", response_class=HTMLResponse)
|
@router.get("/setup", response_class=HTMLResponse)
|
||||||
async def setup_page(
|
async def setup_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
role: Literal["analyst", "admin"] = Query(default="admin"),
|
||||||
user: Optional[dict] = Depends(get_optional_user),
|
user: Optional[dict] = Depends(get_optional_user),
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||||
):
|
):
|
||||||
"""Setup instructions for the local agent (CLI + Claude Code).
|
"""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
|
When an admin override is saved, the override replaces the auto-generated
|
||||||
setup_instructions output everywhere (both the /setup page display and the
|
setup_instructions output everywhere (both the /setup page display and the
|
||||||
dashboard clipboard CTA). When no override is set, the live default from
|
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("/")
|
base_url = str(request.base_url).rstrip("/")
|
||||||
|
|
||||||
# Determine the script text: override (Jinja2-rendered) or live default.
|
# 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()
|
row = WelcomeTemplateRepository(conn).get()
|
||||||
override_content = row.get("content")
|
override_content = row.get("content")
|
||||||
if override_content:
|
if override_content:
|
||||||
|
|
@ -748,9 +759,13 @@ async def setup_page(
|
||||||
setup_script_text = _sanitize_banner_html(template.render(**ctx_vars))
|
setup_script_text = _sanitize_banner_html(template.render(**ctx_vars))
|
||||||
except (TemplateError, Exception) as exc:
|
except (TemplateError, Exception) as exc:
|
||||||
logger.warning("setup_page: override render failed (%s); falling back to default", 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:
|
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
|
# Split for the legacy setup_instructions_lines list variable that the
|
||||||
# Jinja2 partial (_claude_setup_instructions.jinja) uses.
|
# 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.
|
# Override both variables so the partial and the JS array stay in sync.
|
||||||
setup_instructions_lines=setup_instructions_lines,
|
setup_instructions_lines=setup_instructions_lines,
|
||||||
setup_script_text=setup_script_text,
|
setup_script_text=setup_script_text,
|
||||||
|
role=role,
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(request, "install.html", ctx)
|
return templates.TemplateResponse(request, "install.html", ctx)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -629,6 +629,46 @@
|
||||||
.manual-body { padding: 16px 18px 18px; }
|
.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) ── */
|
/* ── Admin-configured banner (above setup commands) ── */
|
||||||
.setup-banner {
|
.setup-banner {
|
||||||
background: var(--background, #f6f7f9);
|
background: var(--background, #f6f7f9);
|
||||||
|
|
@ -663,6 +703,22 @@
|
||||||
|
|
||||||
<main class="main">
|
<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) ═══════════════ -->
|
<!-- ═══════════════ ADMIN BANNER (optional) ═══════════════ -->
|
||||||
{% if banner_html %}<div class="setup-banner">{{ banner_html | safe }}</div>{% endif %}
|
{% if banner_html %}<div class="setup-banner">{{ banner_html | safe }}</div>{% endif %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import os
|
||||||
import re
|
import re
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import duckdb
|
import duckdb
|
||||||
|
|
@ -130,6 +130,7 @@ def compute_default_agent_prompt(
|
||||||
*,
|
*,
|
||||||
user: dict[str, Any] | None,
|
user: dict[str, Any] | None,
|
||||||
server_url: str,
|
server_url: str,
|
||||||
|
role: Literal["analyst", "admin"] = "admin",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Return the live default setup script from setup_instructions.resolve_lines().
|
"""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
|
``conn`` and ``user`` are forwarded to resolve the RBAC-filtered plugin
|
||||||
install list (anonymous visitors / no conn get the no-marketplace layout).
|
install list (anonymous visitors / no conn get the no-marketplace layout).
|
||||||
``server_url`` is used to derive the server host for the marketplace block.
|
``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:
|
try:
|
||||||
from app.web.setup_instructions import resolve_lines
|
from app.web.setup_instructions import resolve_lines
|
||||||
|
|
@ -148,8 +153,12 @@ def compute_default_agent_prompt(
|
||||||
_wheel = _find_wheel()
|
_wheel = _find_wheel()
|
||||||
_wheel_filename = _wheel.name if _wheel else "agnes.whl"
|
_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] = []
|
plugin_install_names: list[str] = []
|
||||||
if user and conn is not None:
|
if role == "admin" and user and conn is not None:
|
||||||
try:
|
try:
|
||||||
from src import marketplace_filter
|
from src import marketplace_filter
|
||||||
plugin_install_names = [
|
plugin_install_names = [
|
||||||
|
|
@ -179,6 +188,7 @@ def compute_default_agent_prompt(
|
||||||
self_signed_tls=self_signed_tls,
|
self_signed_tls=self_signed_tls,
|
||||||
server_host=server_host,
|
server_host=server_host,
|
||||||
ca_pem=ca_pem,
|
ca_pem=ca_pem,
|
||||||
|
role=role,
|
||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
75
tests/test_setup_page_roles.py
Normal file
75
tests/test_setup_page_roles.py
Normal 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)
|
||||||
Loading…
Reference in a new issue