feat(setup): add analyst role to install-prompt renderer

This commit is contained in:
ZdenekSrotyr 2026-05-04 17:17:59 +02:00
parent 59324f9361
commit 29e28ccbd3
2 changed files with 130 additions and 1 deletions

View file

@ -103,6 +103,8 @@ permitted that fallback chain — it's not improvising-around-a-TLS-error.
from __future__ import annotations
from typing import Literal
# Marketplace name as published by app.marketplace_server.packager.
# Hard-coded here (rather than imported) to keep this module dependency-free
# and trivially testable. If the value ever drifts, the regression test
@ -606,6 +608,50 @@ def _preamble_lines(*, has_ca: bool) -> list[str]:
return lines
def _analyst_init_lines(server_url_placeholder: str = "{server_url}") -> list[str]:
"""Steps 2-3 — `agnes init` (auth + workspace bootstrap) + smoke verify.
Replaces the admin-flow login + verify steps. `agnes init` is non-interactive:
`--token` carries the PAT, `--server-url` carries the origin. The bootstrap
PAT has a 1 h TTL if the user takes longer than that to paste this prompt,
the init call returns 401 and the user re-clicks "Generate prompt" on the
install page.
"""
return [
"",
"2) Bootstrap your analyst workspace in this directory:",
f" agnes init --server-url \"{server_url_placeholder}\" --token \"{{token}}\" --workspace .",
"",
" This authenticates with the PAT, fetches your CLAUDE.md (RBAC-filtered),",
" installs Claude Code SessionStart/End hooks (auto-refresh), and runs an",
" initial `agnes pull` so your DuckDB views are ready.",
"",
"3) Verify the data is queryable:",
" agnes catalog",
"",
" This should list the tables your account has grants for. Empty list",
" means your admin hasn't granted you access yet — contact them.",
]
def _analyst_finale_lines(confirm_step_num: str, has_ca: bool) -> list[str]:
"""Final Confirm step for analyst role. Shorter than admin: no marketplace, no plugins, no skills."""
bullets = [
" - `agnes --version` output",
" - First few lines of `agnes catalog` (tables you can see)",
" - Confirmation that `./CLAUDE.md` and `./AGNES_WORKSPACE.md` exist",
" - Confirmation that `./.claude/settings.json` contains SessionStart/End hooks",
]
if has_ca:
bullets.append(" - Which CA bundle source got picked in step 0(d)")
return [
"",
f"{confirm_step_num}) Confirm:",
" Tell me \"Agnes analyst workspace is ready\" and summarize:",
*bullets,
]
def resolve_lines(
wheel_filename: str,
*,
@ -613,6 +659,7 @@ def resolve_lines(
self_signed_tls: bool = False,
server_host: str = "",
ca_pem: str | None = None,
role: Literal["analyst", "admin"] = "admin",
) -> list[str]:
"""Return the template lines with server-side placeholders substituted.
@ -643,7 +690,14 @@ def resolve_lines(
The resulting URL (`/cli/wheel/agnes.whl`) will 404 at download time, but
the instruction text still renders so operators can see the snippet shape
and diagnose the missing wheel on the server.
`role="analyst"` short-circuits to the analyst-workspace layout
(`_resolve_analyst_lines`) see that function for the layout. Default
`role="admin"` keeps the admin layout below byte-identical to before.
"""
if role == "analyst":
return _resolve_analyst_lines(wheel_filename, ca_pem=ca_pem)
names = list(plugin_install_names or [])
has_marketplace = bool(names)
has_ca = bool(ca_pem and ca_pem.strip())
@ -693,6 +747,30 @@ def resolve_lines(
]
def _resolve_analyst_lines(wheel_filename: str, *, ca_pem: str | None) -> list[str]:
"""Analyst workspace-bootstrap layout. Self-contained — no admin-only steps.
Drops marketplace, plugins, skills, diagnose, login, and whoami (all of
those are admin-only or subsumed by `agnes init`). Reuses the trust
block, preamble, and install-CLI helpers from the admin path.
"""
has_ca = bool(ca_pem and ca_pem.strip())
confirm_step = "4" # numbering: 0 (TLS optional), 1, 2, 3, 4
lines: list[str] = []
if has_ca:
lines.extend(_tls_trust_block(ca_pem)) # type: ignore[arg-type]
lines.extend(_preamble_lines(has_ca=has_ca))
lines.extend(_install_cli_lines(has_ca=has_ca)) # step 1
lines.extend(_analyst_init_lines()) # steps 2-3
lines.extend(_analyst_finale_lines(confirm_step, has_ca=has_ca)) # step 4
return [
line.replace("{wheel_filename}", wheel_filename)
for line in lines
]
def render_setup_instructions(
server_url: str,
token: str,
@ -702,13 +780,14 @@ def render_setup_instructions(
self_signed_tls: bool = False,
server_host: str = "",
ca_pem: str | None = None,
role: Literal["analyst", "admin"] = "admin",
) -> str:
"""Render the setup instructions as a single string.
Used server-side for tests and any non-JS rendering path. The browser
clipboard flow uses the JS renderer embedded in the Jinja partial; both
must produce byte-identical output for a given (server_url, token,
wheel, plugins, flag, host, ca_pem) tuple.
wheel, plugins, flag, host, ca_pem, role) tuple.
"""
lines = resolve_lines(
wheel_filename,
@ -716,6 +795,7 @@ def render_setup_instructions(
self_signed_tls=self_signed_tls,
server_host=server_host,
ca_pem=ca_pem,
role=role,
)
text = "\n".join(lines)
return text.replace("{server_url}", server_url).replace("{token}", token)

View file

@ -0,0 +1,49 @@
"""Tests for analyst-branch rendering of /setup paste prompt."""
from app.web.setup_instructions import render_setup_instructions
def test_render_analyst_role_basic():
text = render_setup_instructions(
server_url="https://agnes.example.com",
token="agnes_pat_TEST",
wheel_filename="agnes-0.32.0-py3-none-any.whl",
role="analyst",
)
# Required content for analyst role:
assert "uv tool install" in text
assert "agnes init" in text
assert "--token" in text and "agnes_pat_TEST" in text
assert "--server-url" in text and "https://agnes.example.com" in text
assert "agnes catalog" in text # smoke verify step
# Forbidden content (admin-only):
assert "marketplace" not in text
assert "claude plugin install" not in text
assert "agnes skills install" not in text
assert "agnes diagnose" not in text
def test_render_admin_role_unchanged():
"""Default role=admin keeps the existing layout."""
text = render_setup_instructions(
server_url="https://agnes.example.com",
token="agnes_pat_TEST",
wheel_filename="agnes-0.32.0-py3-none-any.whl",
# role omitted — defaults to "admin"
)
assert "agnes auth import-token" in text # admin uses import-token, not agnes init
assert "agnes diagnose" in text # admin keeps diagnose
def test_render_analyst_with_ca_pem():
"""Analyst role + private CA → TLS trust block reused from admin path."""
text = render_setup_instructions(
server_url="https://agnes.example.com",
token="agnes_pat_TEST",
wheel_filename="agnes-0.32.0-py3-none-any.whl",
role="analyst",
ca_pem="-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----",
)
assert "AGNES_CA_PEM" in text # heredoc marker from trust block
assert "ca-bundle.pem" in text
assert "agnes init" in text # analyst-specific step still present