feat(web): rename /install → /setup; nav label 'Setup local agent'

- Add GET /setup serving install.html (CLI + Claude Code setup page)
- Add GET /install → 301 redirect to /setup for backwards compat
- Move first-time setup wizard from /setup to /first-time-setup
- Update nav link: href=/setup, label 'Setup local agent', active on both /setup and /install paths
- Update page <title> to 'Setup local agent — …'
- Update /dashboard and /setup comment in _claude_setup_instructions.jinja
- Update tests and OpenAPI snapshot accordingly
This commit is contained in:
ZdenekSrotyr 2026-05-02 19:58:20 +02:00
parent 92fd78cfb4
commit 85967e14ca
10 changed files with 532 additions and 54 deletions

View file

@ -120,7 +120,7 @@ _URL_MAP = {
"email_auth.login_email_form": "/login/email",
"email_auth.send_magic_link": "/auth/email/send-link",
"register": "/auth/password/setup",
"setup": "/setup",
"setup": "/first-time-setup",
}
@ -322,9 +322,9 @@ async def index(request: Request, user: Optional[dict] = Depends(get_optional_us
return RedirectResponse(url="/login", status_code=302)
@router.get("/setup", response_class=HTMLResponse)
@router.get("/first-time-setup", response_class=HTMLResponse)
async def setup_wizard(request: Request, conn: duckdb.DuckDBPyConnection = Depends(_get_db)):
"""First-time setup wizard. Redirects to dashboard if users already exist."""
"""First-time setup wizard. Redirects to login if users already exist."""
try:
user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if user_count > 0:
@ -720,13 +720,13 @@ async def activity_center(
return templates.TemplateResponse(request, "activity_center.html", ctx)
@router.get("/install", response_class=HTMLResponse)
async def install_page(
@router.get("/setup", response_class=HTMLResponse)
async def setup_page(
request: Request,
user: Optional[dict] = Depends(get_optional_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Public install instructions for the CLI."""
"""Setup instructions for the local agent (CLI + Claude Code)."""
base_url = str(request.base_url).rstrip("/")
ctx = _build_context(
request,
@ -738,6 +738,12 @@ async def install_page(
return templates.TemplateResponse(request, "install.html", ctx)
@router.get("/install", response_class=HTMLResponse)
async def install_redirect(request: Request):
"""Backwards-compat redirect: /install → /setup (301)."""
return RedirectResponse(url="/setup", status_code=301)
@router.get("/admin/tables", response_class=HTMLResponse)
async def admin_tables(
request: Request,

View file

@ -11,7 +11,7 @@
<div class="app-header-right">
{% set _path = request.url.path %}
<a class="app-nav-link {% if _path == '/dashboard' or _path == '/' %}is-active{% endif %}" href="/dashboard">Dashboard</a>
<a class="app-nav-link {% if _path.startswith('/install') %}is-active{% endif %}" href="/install">Install CLI</a>
<a class="app-nav-link {% if _path.startswith('/setup') or _path.startswith('/install') %}is-active{% endif %}" href="/setup">Setup local agent</a>
{% if session.user.is_admin %}
<a class="app-nav-link {% if _path.startswith('/admin/marketplaces') %}is-active{% endif %}" href="/admin/marketplaces">Marketplaces</a>
{% set _admin_active = _path.startswith('/admin/tables') or _path.startswith('/admin/tokens') or _path.startswith('/admin/users') or _path.startswith('/admin/groups') or _path.startswith('/admin/access') or _path.startswith('/admin/server-config') or _path.startswith('/admin/welcome') %}

View file

@ -5,7 +5,7 @@
* preview_mode=True → emits a read-only HTML <pre><code> block rendered
with the real server_url and a visible placeholder
for the token. Used inline on /dashboard and
/install so the reader can see exactly what will
/setup so the reader can see exactly what will
land in their clipboard.
* preview_mode=False → emits the JS `SETUP_INSTRUCTIONS_TEMPLATE` array +
`renderSetupInstructions(server, token)` function.

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Install CLI — {{ config.INSTANCE_NAME }}</title>
<title>Setup local agent — {{ config.INSTANCE_NAME }}</title>
{% if not config.THEME_FONT_URL %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View file

@ -1893,6 +1893,8 @@
},
"profile_after_sync": {
"default": true,
"deprecated": true,
"description": "DEPRECATED: not consumed by the runtime (Agent 1 finding 2026-05-01). Profiler runs unconditionally on every synced table; this flag has no effect. Field stays for back-compat.",
"title": "Profile After Sync",
"type": "boolean"
},
@ -1901,6 +1903,17 @@
"title": "Query Mode",
"type": "string"
},
"source_query": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Query"
},
"source_table": {
"anyOf": [
{
@ -1936,6 +1949,8 @@
},
"sync_strategy": {
"default": "full_refresh",
"deprecated": true,
"description": "DEPRECATED: catalog/profiler metadata only. No extractor reads this field; every sync is a full overwrite regardless of value. profiler.is_partitioned() consumes it for parquet-layout detection. Field stays for back-compat; will be removed in a future major release.",
"title": "Sync Strategy",
"type": "string"
}
@ -2067,6 +2082,83 @@
"title": "TableSubscriptionUpdate",
"type": "object"
},
"TemplateGetResponse": {
"properties": {
"content": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Content"
},
"default": {
"title": "Default",
"type": "string"
},
"updated_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Updated At"
},
"updated_by": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Updated By"
}
},
"required": [
"content",
"default"
],
"title": "TemplateGetResponse",
"type": "object"
},
"TemplatePreviewRequest": {
"properties": {
"content": {
"maxLength": 200000,
"minLength": 1,
"title": "Content",
"type": "string"
}
},
"required": [
"content"
],
"title": "TemplatePreviewRequest",
"type": "object"
},
"TemplatePutRequest": {
"properties": {
"content": {
"maxLength": 200000,
"minLength": 1,
"title": "Content",
"type": "string"
}
},
"required": [
"content"
],
"title": "TemplatePutRequest",
"type": "object"
},
"TokenListItem": {
"properties": {
"created_at": {
@ -2329,6 +2421,8 @@
"type": "null"
}
],
"deprecated": true,
"description": "DEPRECATED: not consumed by the runtime. See RegisterTableRequest.profile_after_sync.",
"title": "Profile After Sync"
},
"query_mode": {
@ -2342,6 +2436,17 @@
],
"title": "Query Mode"
},
"source_query": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Query"
},
"source_table": {
"anyOf": [
{
@ -2384,6 +2489,8 @@
"type": "null"
}
],
"deprecated": true,
"description": "DEPRECATED: catalog/profiler metadata only. See RegisterTableRequest.sync_strategy.",
"title": "Sync Strategy"
}
},
@ -2641,13 +2748,26 @@
],
"title": "VoteRequest",
"type": "object"
},
"WelcomeResponse": {
"properties": {
"content": {
"title": "Content",
"type": "string"
}
},
"required": [
"content"
],
"title": "WelcomeResponse",
"type": "object"
}
}
},
"info": {
"description": "Data distribution platform for AI analytical systems",
"title": "AI Data Analyst",
"version": "2.1.0"
"version": "2.0.0"
},
"openapi": "3.1.0",
"paths": {
@ -3238,6 +3358,55 @@
]
}
},
"/admin/welcome": {
"get": {
"operationId": "admin_welcome_page_admin_welcome_get",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Welcome Page",
"tags": [
"web"
]
}
},
"/api/admin/access-overview": {
"get": {
"description": "One-shot snapshot for the /admin/access page.\n\nReturns:\n - ``groups``: every user_group with member + grant counts\n - ``grants``: every (group_id, resource_type, resource_id) row\n - ``resources``: per-resource-type hierarchical layout, where each\n type has a list of *blocks* (parent entities, e.g. a marketplace)\n and each block has *items* (concrete grantable resources).\n\nUI stitches the three pieces into the two-column layout: groups on\nthe left, resources tree on the right with per-item checkboxes whose\nstate derives from ``grants``.",
@ -3398,9 +3567,25 @@
},
"/api/admin/discover-tables": {
"get": {
"description": "Discover all available tables from the configured data source.",
"description": "Discover available tables from the configured data source.\n\nFor ``data_source.type='keboola'`` returns the full Storage API table\nlist (single round-trip). For ``data_source.type='bigquery'``:\n\n- Without ``dataset``: list datasets in the configured project.\n- With ``dataset=name``: list tables (BASE TABLE + VIEW) in that dataset.\n\nTwo-step shape avoids paying the per-dataset list_tables cost up-front\non projects with hundreds of datasets \u2014 the UI populates the dataset\ndropdown first, then fetches tables only for the selected dataset.",
"operationId": "discover_tables_api_admin_discover_tables_get",
"parameters": [
{
"in": "query",
"name": "dataset",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Dataset"
}
},
{
"in": "header",
"name": "authorization",
@ -4591,7 +4776,7 @@
},
"/api/admin/registry": {
"get": {
"description": "Get full table registry.",
"description": "Get full table registry.\n\nEach table row is enriched with `last_sync_error` from sync_state so\noperators can see WHY a row isn't materializing without trawling\nscheduler logs. None for rows that have never errored or have already\nrecovered (status='ok'); the per-row error message string otherwise.",
"operationId": "list_registry_api_admin_registry_get",
"parameters": [
{
@ -4639,7 +4824,7 @@
},
"/api/admin/registry/{table_id}": {
"delete": {
"description": "Unregister a table from the system.\n\nFor BQ rows, schedules a background rebuild so the dropped row's\nmaster view is removed from analytics.duckdb (rather than hanging\naround until the next scheduled sync).",
"description": "Unregister a table from the system.\n\nFor BQ rows, schedules a background rebuild so the dropped row's\nmaster view is removed from analytics.duckdb (rather than hanging\naround until the next scheduled sync).\n\nFor materialized rows, also removes the canonical parquet at\n`${DATA_DIR}/extracts/<source_type>/data/<id>.parquet` and clears\nthe matching `sync_state` row. Without these two cleanups, the\nmanifest endpoint kept advertising the dropped table to `da sync`\n(sync_state-driven) and the orchestrator's next rebuild could\nresurrect a master view from the leftover parquet (E2E sub-agent\nfinding 2026-05-01).",
"operationId": "unregister_table_api_admin_registry__table_id__delete",
"parameters": [
{
@ -5163,6 +5348,210 @@
]
}
},
"/api/admin/welcome-template": {
"delete": {
"operationId": "admin_reset_template_api_admin_welcome_template_delete",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Reset Template",
"tags": [
"welcome"
]
},
"get": {
"operationId": "admin_get_template_api_admin_welcome_template_get",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemplateGetResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Get Template",
"tags": [
"welcome"
]
},
"put": {
"operationId": "admin_put_template_api_admin_welcome_template_put",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemplatePutRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Put Template",
"tags": [
"welcome"
]
}
},
"/api/admin/welcome-template/preview": {
"post": {
"description": "Render arbitrary template content against the live context for the\ncalling admin, without persisting. Used by the /admin/welcome editor's\nPreview button so admins can see their edits before saving.",
"operationId": "admin_preview_template_api_admin_welcome_template_preview_post",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemplatePreviewRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WelcomeResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Preview Template",
"tags": [
"welcome"
]
}
},
"/api/catalog/metrics/{metric_path}": {
"get": {
"deprecated": true,
@ -9900,6 +10289,67 @@
]
}
},
"/api/welcome": {
"get": {
"description": "Render the welcome prompt for the calling user. Returns rendered markdown.",
"operationId": "get_welcome_api_welcome_get",
"parameters": [
{
"description": "The server URL the analyst is bootstrapping against",
"in": "query",
"name": "server_url",
"required": true,
"schema": {
"description": "The server URL the analyst is bootstrapping against",
"title": "Server Url",
"type": "string"
}
},
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WelcomeResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Get Welcome",
"tags": [
"welcome"
]
}
},
"/auth/admin/tokens": {
"get": {
"operationId": "admin_list_tokens_auth_admin_tokens_get",
@ -11153,28 +11603,10 @@
]
}
},
"/install": {
"/first-time-setup": {
"get": {
"description": "Public install instructions for the CLI.",
"operationId": "install_page_install_get",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"description": "First-time setup wizard. Redirects to login if users already exist.",
"operationId": "setup_wizard_first_time_setup_get",
"responses": {
"200": {
"content": {
@ -11185,19 +11617,31 @@
}
},
"description": "Successful Response"
}
},
"422": {
"summary": "Setup Wizard",
"tags": [
"web"
]
}
},
"/install": {
"get": {
"description": "Backwards-compat redirect: /install \u2192 /setup (301).",
"operationId": "install_redirect_install_get",
"responses": {
"200": {
"content": {
"application/json": {
"text/html": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
"type": "string"
}
}
},
"description": "Validation Error"
"description": "Successful Response"
}
},
"summary": "Install Page",
"summary": "Install Redirect",
"tags": [
"web"
]
@ -11511,8 +11955,26 @@
},
"/setup": {
"get": {
"description": "First-time setup wizard. Redirects to dashboard if users already exist.",
"operationId": "setup_wizard_setup_get",
"description": "Setup instructions for the local agent (CLI + Claude Code).",
"operationId": "setup_page_setup_get",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"content": {
@ -11523,9 +11985,19 @@
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"summary": "Setup Wizard",
"description": "Validation Error"
}
},
"summary": "Setup Page",
"tags": [
"web"
]

View file

@ -100,7 +100,7 @@ def test_install_page_renders_with_server_url(tmp_path, monkeypatch):
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
resp = client.get("/install", headers={"host": "agnes.test", "Accept": "text/html"})
resp = client.get("/setup", headers={"host": "agnes.test", "Accept": "text/html"})
assert resp.status_code == 200
assert "agnes.test" in resp.text
assert "da auth whoami" in resp.text

View file

@ -41,20 +41,20 @@ def test_parquet_path_is_not_gzipped(isolated_client, tmp_path, monkeypatch):
def test_install_page_is_gzipped(isolated_client):
"""/install is HTML above the threshold — gzip should kick in when the
"""/setup is HTML above the threshold — gzip should kick in when the
client advertises gzip support. TestClient may decompress transparently,
so we accept either the header or readable body as proof that the
middleware decided to handle the response (i.e. did not skip)."""
resp = isolated_client.get("/install", headers={"Accept-Encoding": "gzip"})
resp = isolated_client.get("/setup", headers={"Accept-Encoding": "gzip"})
assert resp.status_code == 200
enc = resp.headers.get("content-encoding", "")
# Either we see the encoding on the wire OR TestClient auto-decoded it.
assert "gzip" in enc or "install" in resp.text.lower()
assert "gzip" in enc or "setup" in resp.text.lower()
def test_no_accept_encoding_means_no_gzip_anywhere(isolated_client):
"""Client that doesn't advertise gzip gets uncompressed body."""
resp = isolated_client.get("/install", headers={"Accept-Encoding": "identity"})
resp = isolated_client.get("/setup", headers={"Accept-Encoding": "identity"})
assert resp.status_code == 200
assert "gzip" not in resp.headers.get("content-encoding", "")

View file

@ -789,7 +789,7 @@ def test_install_page_uses_versioned_wheel_url(monkeypatch, tmp_path):
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
resp = client.get("/install", headers={"host": "agnes.test", "Accept": "text/html"})
resp = client.get("/setup", headers={"host": "agnes.test", "Accept": "text/html"})
assert resp.status_code == 200
assert "/cli/wheel/agnes_the_ai_analyst-2.0.0-py3-none-any.whl" in resp.text
# The bare alias must no longer appear in the rendered snippet.

View file

@ -54,7 +54,7 @@ def app_no_toolbar(monkeypatch, tmp_path, reset_logging_state):
@pytest.mark.integration
def test_no_toolbar_when_debug_off(app_no_toolbar):
client = TestClient(app_no_toolbar)
resp = client.get("/setup", follow_redirects=False)
resp = client.get("/first-time-setup", follow_redirects=False)
if resp.status_code in (302, 401):
# Auth redirect — toolbar wouldn't render anyway. The point of this
# test is to assert markup ABSENCE; no markup, no failure.
@ -76,7 +76,7 @@ def test_toolbar_html_present_when_debug(app_with_toolbar):
client = TestClient(app_with_toolbar)
# Try several HTML routes — at least one should respond 200 under
# LOCAL_DEV_MODE=1 (auth bypass).
for path in ("/dashboard", "/setup", "/login", "/admin/access"):
for path in ("/dashboard", "/first-time-setup", "/login", "/admin/access"):
resp = client.get(path, follow_redirects=False)
if resp.status_code == 200 and "text/html" in resp.headers.get("content-type", ""):
body = resp.text.lower()

View file

@ -214,7 +214,7 @@ class TestClaudeSetupPreview:
"""
def test_install_preview_visible_for_signed_in_user(self, web_client, admin_cookie):
resp = web_client.get("/install", cookies=admin_cookie)
resp = web_client.get("/setup", cookies=admin_cookie)
assert resp.status_code == 200
body = resp.text
# Preview card + placeholder token render
@ -243,10 +243,10 @@ class TestClaudeSetupPreview:
assert "&lt;will be generated on click&gt;" in body
def test_install_mcp_card_removed(self, web_client):
"""The stale 'Use with Claude Code / MCP' card on /install has been
"""The stale 'Use with Claude Code / MCP' card on /setup has been
removed there is no Agnes MCP server today.
"""
resp = web_client.get("/install")
resp = web_client.get("/setup")
assert resp.status_code == 200
body = resp.text
assert "Use with Claude Code / MCP" not in body