From 955b56608dc23fd6efc0eb386d1804cba7e977eb Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr Date: Sun, 3 May 2026 22:44:14 +0200 Subject: [PATCH] feat(api,web,cli): /admin/workspace-prompt + /api/welcome restored + da analyst writes CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/api/claude_md.py: GET /api/welcome (analyst, auth required); GET/PUT/DELETE /api/admin/workspace-prompt-template; POST …/preview; two-pass Jinja2 validation on PUT; validation stub mirrors build_claude_md_context() shape - app/main.py: register claude_md_router - app/web/router.py: GET /admin/workspace-prompt → admin_workspace_prompt.html - app/web/templates/admin_workspace_prompt.html: CodeMirror editor + live preview + status chip + reset modal; mirrors admin_welcome.html for Agent Setup Prompt - app/web/templates/_app_header.html: add "Agent Workspace Prompt" nav item next to "Agent Setup Prompt"; extend _admin_active to cover /admin/workspace-prompt - cli/commands/analyst.py: _init_claude_workspace now accepts server_url + token; _write_claude_md fetches GET /api/welcome, writes CLAUDE.md, graceful 404/5xx; setup command adds --no-claude-md flag to opt out; default = write CLAUDE.md - tests: test_claude_md_api.py (16 tests); test_analyst_bootstrap.py updated with 4 new CLAUDE.md bootstrap tests; test_welcome_template_api.py: update stale assertion about /api/welcome being removed (endpoint restored) - tests/snapshots/openapi.json: regenerated --- app/api/claude_md.py | 206 +++++++ app/main.py | 2 + app/web/router.py | 24 + app/web/templates/_app_header.html | 3 +- app/web/templates/admin_workspace_prompt.html | 529 ++++++++++++++++++ cli/commands/analyst.py | 72 ++- tests/snapshots/openapi.json | 335 +++++++++++ tests/test_analyst_bootstrap.py | 55 +- tests/test_claude_md_api.py | 257 +++++++++ tests/test_welcome_template_api.py | 11 +- 10 files changed, 1478 insertions(+), 16 deletions(-) create mode 100644 app/api/claude_md.py create mode 100644 app/web/templates/admin_workspace_prompt.html create mode 100644 tests/test_claude_md_api.py diff --git a/app/api/claude_md.py b/app/api/claude_md.py new file mode 100644 index 0000000..dcb9d82 --- /dev/null +++ b/app/api/claude_md.py @@ -0,0 +1,206 @@ +"""REST endpoints for the agent-workspace-prompt (analyst CLAUDE.md). + +- GET /api/welcome : analyst-facing rendered CLAUDE.md (auth required) +- GET /api/admin/workspace-prompt-template : raw template override + live default (admin) +- PUT /api/admin/workspace-prompt-template : set override (admin) +- DELETE /api/admin/workspace-prompt-template : reset to default (admin) +- POST /api/admin/workspace-prompt-template/preview : live preview without persisting (admin) +""" + +import datetime +import logging +from typing import Optional +from urllib.parse import unquote + +import duckdb +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from jinja2 import Environment, StrictUndefined, TemplateError +from pydantic import BaseModel, Field + +from app.auth.access import require_admin +from app.auth.dependencies import _get_db, get_current_user +from src.repositories.claude_md_template import ClaudeMdTemplateRepository +from src.claude_md import build_claude_md_context, compute_default_claude_md, render_claude_md + +logger = logging.getLogger(__name__) + + +router = APIRouter(tags=["claude_md"]) + +# Stub context used to validate that a saved template renders end-to-end, +# not just that it parses. Mirrors the shape of build_claude_md_context() output. +# user is an authenticated user so templates that reference user.* are validated. +_VALIDATION_STUB_CONTEXT = { + "instance": {"name": "Example", "subtitle": "Example Org"}, + "server": {"url": "https://example.com", "hostname": "example.com"}, + "sync_interval": "1h", + "data_source": {"type": "keboola"}, + "tables": [{"name": "orders", "description": "Sample orders", "query_mode": "local"}], + "metrics": {"count": 3, "categories": ["revenue", "growth"]}, + "marketplaces": [{"slug": "example", "name": "Example Marketplace", "plugins": [{"name": "plugin-a"}]}], + "user": { + "id": "u", + "email": "user@example.com", + "name": "User", + "is_admin": False, + "groups": ["Everyone"], + }, + "now": datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc), + "today": "2026-01-01", +} + +# Same stub with an anonymous-style user context to validate templates against +# the case where a user dict is present but minimal (analyst). The CLAUDE.md +# endpoint always requires auth, so user is never None — but templates may +# accidentally reference fields that aren't in the context. +_VALIDATION_STUB_CONTEXT_ANON = { + **{k: v for k, v in _VALIDATION_STUB_CONTEXT.items() if k != "user"}, + "user": { + "id": "u2", + "email": "anon@example.com", + "name": "", + "is_admin": False, + "groups": ["Everyone"], + }, +} + + +class ClaudeMdResponse(BaseModel): + content: str + + +class TemplateGetResponse(BaseModel): + content: Optional[str] + default: str # live default rendered with calling admin's context + updated_at: Optional[str] = None + updated_by: Optional[str] = None + + +class TemplatePutRequest(BaseModel): + content: str = Field(..., min_length=1, max_length=200_000) + + +class TemplatePreviewRequest(BaseModel): + content: str = Field(..., min_length=1, max_length=200_000) + + +# --------------------------------------------------------------------------- +# Analyst-facing endpoint — returns rendered CLAUDE.md +# --------------------------------------------------------------------------- + +@router.get("/api/welcome", response_model=ClaudeMdResponse) +async def get_welcome( + request: Request, + server_url: Optional[str] = Query(None, description="Server URL used in rendered CLAUDE.md"), + user: dict = Depends(get_current_user), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + """Return the rendered CLAUDE.md for the authenticated analyst. + + The CLI calls this endpoint during ``da analyst setup`` to write + ``/CLAUDE.md``. The content is RBAC-filtered per the + calling user. + + ``server_url`` query param lets the CLI pass the origin it knows so + the rendered content references the correct server URL rather than the + request host (which may differ behind a proxy). + """ + effective_url = server_url or str(request.base_url).rstrip("/") + try: + content = render_claude_md(conn, user=user, server_url=effective_url) + except TemplateError as exc: + logger.warning("render_claude_md failed (template error): %s", exc) + raise HTTPException(status_code=500, detail=f"Template render error: {exc}") + except Exception: + logger.exception("render_claude_md failed (unexpected)") + raise HTTPException(status_code=500, detail="Internal error rendering CLAUDE.md") + return ClaudeMdResponse(content=content) + + +# --------------------------------------------------------------------------- +# Admin endpoints — CRUD for the workspace-prompt template override +# --------------------------------------------------------------------------- + +@router.get("/api/admin/workspace-prompt-template", response_model=TemplateGetResponse) +async def admin_get_workspace_template( + request: Request, + user: dict = Depends(require_admin), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + row = ClaudeMdTemplateRepository(conn).get() + server_url = str(request.base_url).rstrip("/") + live_default = compute_default_claude_md(conn, user=user, server_url=server_url) + return TemplateGetResponse( + content=row["content"], + default=live_default, + updated_at=row["updated_at"].isoformat() if row["updated_at"] else None, + updated_by=row["updated_by"], + ) + + +@router.put("/api/admin/workspace-prompt-template") +async def admin_put_workspace_template( + payload: TemplatePutRequest, + user: dict = Depends(require_admin), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + """Save an admin override for the analyst CLAUDE.md template. + + Two-pass Jinja2 validation (autoescape=False, StrictUndefined): + - Pass 1: render with an authenticated user stub — catches undefined + placeholders and syntax errors. + - Pass 2: render with a minimal anon-style user stub — catches templates + that hard-depend on admin-only context fields. + """ + env = Environment(undefined=StrictUndefined, autoescape=False) + try: + template = env.from_string(payload.content) + template.render(**_VALIDATION_STUB_CONTEXT) + except TemplateError as e: + raise HTTPException(status_code=400, detail=f"Template invalid: {e}") + + try: + template.render(**_VALIDATION_STUB_CONTEXT_ANON) + except TemplateError as e: + raise HTTPException( + status_code=400, + detail=( + f"Template fails for non-admin analyst users: {e}. " + "Wrap user-dependent expressions in {{% if user.is_admin %}}...{{% endif %}} " + "or ensure the template renders correctly for all users." + ), + ) + + ClaudeMdTemplateRepository(conn).set(payload.content, updated_by=user["email"]) + return {"status": "ok"} + + +@router.delete("/api/admin/workspace-prompt-template", status_code=204) +async def admin_reset_workspace_template( + user: dict = Depends(require_admin), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + ClaudeMdTemplateRepository(conn).reset(updated_by=user["email"]) + return Response(status_code=204) + + +@router.post("/api/admin/workspace-prompt-template/preview", response_model=ClaudeMdResponse) +async def admin_preview_workspace_template( + payload: TemplatePreviewRequest, + request: Request, + user: dict = Depends(require_admin), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + """Render arbitrary template content against the live RBAC context for the + calling admin, without persisting. Used by the /admin/workspace-prompt editor's + Preview button so admins can see their edits before saving.""" + env = Environment(undefined=StrictUndefined, autoescape=False) + try: + template = env.from_string(payload.content) + ctx = build_claude_md_context( + conn, user=user, server_url=str(request.base_url).rstrip("/") + ) + rendered = template.render(**ctx) + except TemplateError as e: + raise HTTPException(status_code=400, detail=f"Template invalid: {e}") + return ClaudeMdResponse(content=rendered) diff --git a/app/main.py b/app/main.py index 296135b..b858749 100644 --- a/app/main.py +++ b/app/main.py @@ -122,6 +122,7 @@ from app.api.v2_sample import router as v2_sample_router from app.api.v2_scan import router as v2_scan_router from app.api.marketplaces import router as marketplaces_router from app.api.welcome import router as welcome_router +from app.api.claude_md import router as claude_md_router from app.marketplace_server.router import router as marketplace_server_router from app.marketplace_server.git_router import make_git_wsgi_app from app.web.router import router as web_router @@ -529,6 +530,7 @@ def create_app() -> FastAPI: app.include_router(v2_scan_router) app.include_router(marketplaces_router) app.include_router(welcome_router) + app.include_router(claude_md_router) app.include_router(marketplace_server_router) # Git smart-HTTP endpoint for Claude Code: /marketplace.git/* diff --git a/app/web/router.py b/app/web/router.py index 603bebb..3a5a3c5 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -950,6 +950,30 @@ async def admin_agent_prompt_page( return templates.TemplateResponse(request, "admin_welcome.html", ctx) +@router.get("/admin/workspace-prompt", response_class=HTMLResponse) +async def admin_workspace_prompt_page( + request: Request, + user: dict = Depends(require_admin), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + from src.repositories.claude_md_template import ClaudeMdTemplateRepository + from src.claude_md import compute_default_claude_md + + row = ClaudeMdTemplateRepository(conn).get() + server_url = str(request.base_url).rstrip("/") + default_template = compute_default_claude_md(conn, user=user, server_url=server_url) + ctx = _build_context( + request, + user=user, + current=row["content"] or "", + default_template=default_template, + updated_at=row["updated_at"], + updated_by=row["updated_by"], + is_override=row["content"] is not None, + ) + return templates.TemplateResponse(request, "admin_workspace_prompt.html", ctx) + + @router.get("/tokens", response_class=HTMLResponse) async def my_tokens_page( diff --git a/app/web/templates/_app_header.html b/app/web/templates/_app_header.html index 49a4790..8d360fb 100644 --- a/app/web/templates/_app_header.html +++ b/app/web/templates/_app_header.html @@ -14,7 +14,7 @@ Setup local agent {% if session.user.is_admin %} Marketplaces - {% 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/agent-prompt') %} + {% 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/agent-prompt') or _path.startswith('/admin/workspace-prompt') %} {% endif %} diff --git a/app/web/templates/admin_workspace_prompt.html b/app/web/templates/admin_workspace_prompt.html new file mode 100644 index 0000000..e8c1d2b --- /dev/null +++ b/app/web/templates/admin_workspace_prompt.html @@ -0,0 +1,529 @@ +{% extends "base.html" %} +{% block title %}Agent Workspace Prompt — {{ config.INSTANCE_NAME }}{% endblock %} + +{% block content %} + + + + + + + + + +
+
+
+

Agent Workspace Prompt

+

Customize the CLAUDE.md Claude Code reads when it opens the analyst workspace.

+
+
+ {% if is_override %} + + Override active + + {% else %} + Using default + {% endif %} +
+
+ +
+
+

+ Default: a rich markdown briefing about Agnes commands, registered tables + (RBAC-filtered for the calling analyst), available metrics, and marketplace plugins. + Written to CLAUDE.md in the analyst workspace at da analyst setup time. + Use --no-claude-md to skip writing it. +

+

+ Template engine: Jinja2 with StrictUndefined — unknown placeholders + raise an error at save time. Use {{ "{% if user.is_admin %}" }}…{{ "{% endif %}" }} + to guard admin-only context. +

+ +
+ Available Jinja2 placeholders +
+ {{ "{{ instance.name }}" }} — instance display name +{{ "{{ instance.subtitle }}" }} — operator / org name +{{ "{{ server.url }}" }} — full server URL +{{ "{{ server.hostname }}" }} — host part only +{{ "{{ sync_interval }}" }} — e.g. "1h" +{{ "{{ data_source.type }}" }} — keboola | bigquery | local + +{{ "{{ tables }}" }} — list of {name, description, query_mode} +{{ "{% for t in tables %}" }} {{ "{{ t.name }}" }}, {{ "{{ t.description }}" }}, {{ "{{ t.query_mode }}" }} {{ "{% endfor %}" }} + +{{ "{{ metrics.count }}" }} — total number of metrics +{{ "{{ metrics.categories }}" }} — list of category names + +{{ "{{ marketplaces }}" }} — list of {slug, name, plugins:[{name}]} +{{ "{% for mp in marketplaces %}" }} {{ "{{ mp.name }}" }}, {{ "{{ mp.slug }}" }}, {{ "{{ mp.plugins }}" }} {{ "{% endfor %}" }} + +{{ "{{ user.id }}" }}, {{ "{{ user.email }}" }}, {{ "{{ user.name }}" }} +{{ "{{ user.is_admin }}" }}, {{ "{{ user.groups }}" }} + +{{ "{{ now }}" }} — tz-aware UTC datetime +{{ "{{ today }}" }} — ISO date string e.g. "2026-01-01" + +
+
+ +
+
+

Editor

+
+ +
+
+
+

Live preview

+
+
(rendering…)
+ +
+
+
+
+
+ + +
+
+
+ + + + +
+ + +{% endblock %} diff --git a/cli/commands/analyst.py b/cli/commands/analyst.py index c1a6374..411a768 100644 --- a/cli/commands/analyst.py +++ b/cli/commands/analyst.py @@ -297,11 +297,15 @@ def _install_claude_hooks(settings_path: Path) -> None: # Helper: initialise Claude workspace (.claude/ directory) # --------------------------------------------------------------------------- -def _init_claude_workspace(workspace: Path) -> None: +def _init_claude_workspace( + workspace: Path, + server_url: str = "", + token: str = "", +) -> None: """Initialise the .claude/ directory with placeholder files and hooks. - Does NOT write CLAUDE.md — workspace-context customisation is handled - server-side via the banner on /setup, not as a file in the workspace. + Writes CLAUDE.md from the server (GET /api/welcome) unless ``server_url`` + or ``token`` are empty, or the request fails (graceful degradation). """ local_md = workspace / ".claude" / "CLAUDE.local.md" if not local_md.exists(): @@ -320,6 +324,57 @@ def _init_claude_workspace(workspace: Path) -> None: _install_claude_hooks(settings_path) + # Write CLAUDE.md from the server + if server_url and token: + _write_claude_md(workspace, server_url, token) + + +def _write_claude_md(workspace: Path, server_url: str, token: str) -> None: + """Fetch the rendered CLAUDE.md from the server and write it to the workspace. + + Gracefully handles: + - 404: older server without the endpoint — skip with warning. + - Other HTTP errors / network errors — skip with warning. + """ + from urllib.parse import urlencode + import httpx + + server_url = server_url.rstrip("/") + params = urlencode({"server_url": server_url}) + url = f"{server_url}/api/welcome?{params}" + try: + resp = httpx.get( + url, + headers={"Authorization": f"Bearer {token}"}, + timeout=30.0, + ) + if resp.status_code == 404: + typer.echo( + "Warning: server does not support CLAUDE.md generation (older version). Skipping.", + err=True, + ) + return + if resp.status_code == 401 or resp.status_code == 403: + typer.echo( + f"Warning: CLAUDE.md fetch failed ({resp.status_code} {resp.reason_phrase}). Skipping.", + err=True, + ) + return + resp.raise_for_status() + data = resp.json() + content = data.get("content", "") + if content: + (workspace / "CLAUDE.md").write_text(content, encoding="utf-8") + else: + typer.echo("Warning: server returned empty CLAUDE.md content. Skipping.", err=True) + except httpx.HTTPStatusError as e: + typer.echo( + f"Warning: CLAUDE.md fetch failed (HTTP {e.response.status_code}). Skipping.", + err=True, + ) + except Exception as e: + typer.echo(f"Warning: CLAUDE.md fetch failed: {e}. Skipping.", err=True) + # --------------------------------------------------------------------------- # Helper: data freshness check (for returning-session detection) @@ -352,6 +407,7 @@ def setup( server_url: str = typer.Option(..., "--server-url", help="URL of the AI Data Analyst server"), force: bool = typer.Option(False, "--force", help="Re-initialise even if workspace already exists"), workspace_dir: Optional[str] = typer.Option(None, "--workspace", help="Workspace directory (default: current dir)"), + no_claude_md: bool = typer.Option(False, "--no-claude-md", help="Skip writing CLAUDE.md to workspace"), ): """Bootstrap a new analyst workspace from a remote server.""" workspace = Path(workspace_dir).resolve() if workspace_dir else Path.cwd() @@ -385,9 +441,13 @@ def setup( typer.echo("Initialising DuckDB views...") total_rows = _initialize_duckdb(workspace) - # 7. Initialise Claude workspace (.claude/ hooks + placeholder) + # 7. Initialise Claude workspace (.claude/ hooks + placeholder + CLAUDE.md) typer.echo("Initializing Claude workspace...") - _init_claude_workspace(workspace) + _init_claude_workspace( + workspace, + server_url=server_url if not no_claude_md else "", + token=token if not no_claude_md else "", + ) # 8. Summary typer.echo("") @@ -396,6 +456,8 @@ def setup( typer.echo(f" Tables : {n_downloaded} downloaded, {total_rows} total rows") typer.echo(f" Workspace: {workspace}") typer.echo(f" Hooks : SessionStart/End installed in {workspace}/.claude/settings.json") + if not no_claude_md: + typer.echo(f" CLAUDE.md: written from server template") typer.echo("") typer.echo("Next steps:") typer.echo(" da sync — refresh data") diff --git a/tests/snapshots/openapi.json b/tests/snapshots/openapi.json index de8cd5e..2a02dc5 100644 --- a/tests/snapshots/openapi.json +++ b/tests/snapshots/openapi.json @@ -397,6 +397,19 @@ "title": "BulkUpdateRequest", "type": "object" }, + "ClaudeMdResponse": { + "properties": { + "content": { + "title": "Content", + "type": "string" + } + }, + "required": [ + "content" + ], + "title": "ClaudeMdResponse", + "type": "object" + }, "ColumnMetadataItem": { "properties": { "basetype": { @@ -3407,6 +3420,55 @@ ] } }, + "/admin/workspace-prompt": { + "get": { + "operationId": "admin_workspace_prompt_page_admin_workspace_prompt_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 Workspace Prompt 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``.", @@ -5552,6 +5614,211 @@ ] } }, + "/api/admin/workspace-prompt-template": { + "delete": { + "operationId": "admin_reset_workspace_template_api_admin_workspace_prompt_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 Workspace Template", + "tags": [ + "claude_md" + ] + }, + "get": { + "operationId": "admin_get_workspace_template_api_admin_workspace_prompt_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 Workspace Template", + "tags": [ + "claude_md" + ] + }, + "put": { + "description": "Save an admin override for the analyst CLAUDE.md template.\n\nTwo-pass Jinja2 validation (autoescape=False, StrictUndefined):\n- Pass 1: render with an authenticated user stub \u2014 catches undefined\n placeholders and syntax errors.\n- Pass 2: render with a minimal anon-style user stub \u2014 catches templates\n that hard-depend on admin-only context fields.", + "operationId": "admin_put_workspace_template_api_admin_workspace_prompt_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 Workspace Template", + "tags": [ + "claude_md" + ] + } + }, + "/api/admin/workspace-prompt-template/preview": { + "post": { + "description": "Render arbitrary template content against the live RBAC context for the\ncalling admin, without persisting. Used by the /admin/workspace-prompt editor's\nPreview button so admins can see their edits before saving.", + "operationId": "admin_preview_workspace_template_api_admin_workspace_prompt_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/ClaudeMdResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Preview Workspace Template", + "tags": [ + "claude_md" + ] + } + }, "/api/catalog/metrics/{metric_path}": { "get": { "deprecated": true, @@ -10289,6 +10556,74 @@ ] } }, + "/api/welcome": { + "get": { + "description": "Return the rendered CLAUDE.md for the authenticated analyst.\n\nThe CLI calls this endpoint during ``da analyst setup`` to write\n``/CLAUDE.md``. The content is RBAC-filtered per the\ncalling user.\n\n``server_url`` query param lets the CLI pass the origin it knows so\nthe rendered content references the correct server URL rather than the\nrequest host (which may differ behind a proxy).", + "operationId": "get_welcome_api_welcome_get", + "parameters": [ + { + "description": "Server URL used in rendered CLAUDE.md", + "in": "query", + "name": "server_url", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Server URL used in rendered CLAUDE.md", + "title": "Server Url" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClaudeMdResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Welcome", + "tags": [ + "claude_md" + ] + } + }, "/auth/admin/tokens": { "get": { "operationId": "admin_list_tokens_auth_admin_tokens_get", diff --git a/tests/test_analyst_bootstrap.py b/tests/test_analyst_bootstrap.py index 1f88e16..a960b6a 100644 --- a/tests/test_analyst_bootstrap.py +++ b/tests/test_analyst_bootstrap.py @@ -139,20 +139,65 @@ class TestCreateWorkspace: # --------------------------------------------------------------------------- class TestInitClaudeWorkspace: - """Tests for _init_claude_workspace: no CLAUDE.md written, but - .claude/CLAUDE.local.md placeholder and settings.json hooks are created. - """ + """Tests for _init_claude_workspace.""" - def test_does_not_write_claude_md(self, tmp_workspace): + def test_does_not_write_claude_md_when_no_server_url(self, tmp_workspace): + """Without server_url, CLAUDE.md must not be written.""" from cli.commands.analyst import _create_workspace, _init_claude_workspace _create_workspace(tmp_workspace) _init_claude_workspace(tmp_workspace) assert not (tmp_workspace / "CLAUDE.md").exists(), ( - "CLAUDE.md must NOT be written by _init_claude_workspace" + "CLAUDE.md must NOT be written when no server_url is provided" ) + def test_writes_claude_md_when_server_returns_200(self, tmp_workspace): + """When /api/welcome returns 200, CLAUDE.md is written.""" + from cli.commands.analyst import _create_workspace, _init_claude_workspace + from unittest.mock import MagicMock, patch + + _create_workspace(tmp_workspace) + + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"content": "# My CLAUDE.md\nHello analyst."} + mock_resp.raise_for_status = MagicMock() + + with patch("cli.commands.analyst.httpx.get", return_value=mock_resp): + _init_claude_workspace(tmp_workspace, server_url="https://example.com", token="tok") + + claude_md = tmp_workspace / "CLAUDE.md" + assert claude_md.exists() + assert "My CLAUDE.md" in claude_md.read_text(encoding="utf-8") + + def test_does_not_write_claude_md_when_no_claude_md_flag(self, tmp_workspace): + """When server_url/token are empty (--no-claude-md path), CLAUDE.md is not written.""" + from cli.commands.analyst import _create_workspace, _init_claude_workspace + + _create_workspace(tmp_workspace) + _init_claude_workspace(tmp_workspace, server_url="", token="") + + assert not (tmp_workspace / "CLAUDE.md").exists() + + def test_does_not_write_claude_md_on_404(self, tmp_workspace): + """When /api/welcome returns 404 (older server), CLAUDE.md is skipped gracefully.""" + from cli.commands.analyst import _create_workspace, _init_claude_workspace + from unittest.mock import MagicMock, patch + import httpx + + _create_workspace(tmp_workspace) + + mock_resp = MagicMock() + mock_resp.status_code = 404 + mock_resp.raise_for_status = MagicMock() + + with patch("cli.commands.analyst.httpx.get", return_value=mock_resp): + # Must not raise + _init_claude_workspace(tmp_workspace, server_url="https://example.com", token="tok") + + assert not (tmp_workspace / "CLAUDE.md").exists() + def test_creates_claude_local_md_when_absent(self, tmp_workspace): from cli.commands.analyst import _create_workspace, _init_claude_workspace diff --git a/tests/test_claude_md_api.py b/tests/test_claude_md_api.py new file mode 100644 index 0000000..8ed6876 --- /dev/null +++ b/tests/test_claude_md_api.py @@ -0,0 +1,257 @@ +"""End-to-end tests for the agent-workspace-prompt API endpoints. + +GET /api/welcome — analyst-facing rendered CLAUDE.md +GET /api/admin/workspace-prompt-template — admin: get template + default +PUT /api/admin/workspace-prompt-template — admin: set override +DELETE /api/admin/workspace-prompt-template — admin: reset to default +POST /api/admin/workspace-prompt-template/preview — admin: live preview +""" + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# GET /api/welcome — analyst-facing rendered CLAUDE.md +# --------------------------------------------------------------------------- + +def test_get_welcome_requires_auth(seeded_app): + """Unauthenticated GET /api/welcome must return 401 or 422.""" + c = seeded_app["client"] + resp = c.get("/api/welcome", params={"server_url": "https://example.com"}) + assert resp.status_code in (401, 422) + + +def test_get_welcome_returns_rendered_markdown(seeded_app): + c = seeded_app["client"] + analyst = _auth(seeded_app["analyst_token"]) + + resp = c.get( + "/api/welcome", + params={"server_url": "https://example.com"}, + headers=analyst, + ) + assert resp.status_code == 200 + body = resp.json() + assert "content" in body + assert isinstance(body["content"], str) + assert body["content"].strip() != "" + + +def test_get_welcome_uses_override_when_set(seeded_app): + c = seeded_app["client"] + admin = _auth(seeded_app["admin_token"]) + analyst = _auth(seeded_app["analyst_token"]) + + # Set an override + r = c.put( + "/api/admin/workspace-prompt-template", + json={"content": "# Custom CLAUDE.md for {{ user.email }}"}, + headers=admin, + ) + assert r.status_code == 200 + + # Analyst fetch should include the override + resp = c.get( + "/api/welcome", + params={"server_url": "https://example.com"}, + headers=analyst, + ) + assert resp.status_code == 200 + assert "Custom CLAUDE.md" in resp.json()["content"] + assert "analyst@test.com" in resp.json()["content"] + + # Reset + c.delete("/api/admin/workspace-prompt-template", headers=admin) + + +# --------------------------------------------------------------------------- +# GET /api/admin/workspace-prompt-template — admin get +# --------------------------------------------------------------------------- + +def test_admin_get_template_initially_null(seeded_app): + c = seeded_app["client"] + admin = _auth(seeded_app["admin_token"]) + + r = c.get("/api/admin/workspace-prompt-template", headers=admin) + assert r.status_code == 200 + body = r.json() + assert body["content"] is None + assert "default" in body + assert body["default"] # non-empty default + + +def test_admin_get_template_default_contains_instance_name(seeded_app): + c = seeded_app["client"] + admin = _auth(seeded_app["admin_token"]) + + r = c.get("/api/admin/workspace-prompt-template", headers=admin) + assert r.status_code == 200 + body = r.json() + # Default template renders the instance name + assert body["default"] != "" + + +def test_non_admin_cannot_get_template(seeded_app): + c = seeded_app["client"] + analyst = _auth(seeded_app["analyst_token"]) + r = c.get("/api/admin/workspace-prompt-template", headers=analyst) + assert r.status_code == 403 + + +# --------------------------------------------------------------------------- +# PUT /api/admin/workspace-prompt-template — save override +# --------------------------------------------------------------------------- + +def test_admin_can_set_and_reset_template(seeded_app): + c = seeded_app["client"] + admin = _auth(seeded_app["admin_token"]) + + # PUT override + r = c.put( + "/api/admin/workspace-prompt-template", + json={"content": "# Hello {{ user.email }}"}, + headers=admin, + ) + assert r.status_code == 200 + + # GET reflects override + r = c.get("/api/admin/workspace-prompt-template", headers=admin) + assert r.status_code == 200 + assert r.json()["content"] == "# Hello {{ user.email }}" + + # DELETE = reset + r = c.delete("/api/admin/workspace-prompt-template", headers=admin) + assert r.status_code == 204 + r = c.get("/api/admin/workspace-prompt-template", headers=admin) + assert r.json()["content"] is None + + +def test_non_admin_cannot_put_template(seeded_app): + c = seeded_app["client"] + analyst = _auth(seeded_app["analyst_token"]) + r = c.put( + "/api/admin/workspace-prompt-template", + json={"content": "# evil override"}, + headers=analyst, + ) + assert r.status_code == 403 + + +def test_invalid_jinja2_returns_400(seeded_app): + c = seeded_app["client"] + admin = _auth(seeded_app["admin_token"]) + r = c.put( + "/api/admin/workspace-prompt-template", + json={"content": "{% for x in y %}"}, # unclosed loop + headers=admin, + ) + assert r.status_code == 400 + assert "invalid" in r.json()["detail"].lower() + + +def test_put_rejects_undefined_placeholder(seeded_app): + c = seeded_app["client"] + admin = _auth(seeded_app["admin_token"]) + r = c.put( + "/api/admin/workspace-prompt-template", + json={"content": "{{ no_such_variable }}"}, + headers=admin, + ) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# DELETE /api/admin/workspace-prompt-template +# --------------------------------------------------------------------------- + +def test_non_admin_cannot_delete_template(seeded_app): + c = seeded_app["client"] + analyst = _auth(seeded_app["analyst_token"]) + r = c.delete("/api/admin/workspace-prompt-template", headers=analyst) + assert r.status_code == 403 + + +# --------------------------------------------------------------------------- +# POST /api/admin/workspace-prompt-template/preview +# --------------------------------------------------------------------------- + +def test_admin_preview_renders_content(seeded_app): + c = seeded_app["client"] + admin = _auth(seeded_app["admin_token"]) + r = c.post( + "/api/admin/workspace-prompt-template/preview", + json={"content": "# Preview for {{ user.email }}"}, + headers=admin, + ) + assert r.status_code == 200 + assert r.json()["content"].startswith("# Preview for admin@test.com") + + +def test_preview_rejects_invalid_template(seeded_app): + c = seeded_app["client"] + admin = _auth(seeded_app["admin_token"]) + r = c.post( + "/api/admin/workspace-prompt-template/preview", + json={"content": "{% for x in y %}"}, + headers=admin, + ) + assert r.status_code == 400 + + +def test_preview_requires_admin(seeded_app): + c = seeded_app["client"] + analyst = _auth(seeded_app["analyst_token"]) + r = c.post( + "/api/admin/workspace-prompt-template/preview", + json={"content": "# Preview"}, + headers=analyst, + ) + assert r.status_code == 403 + + +def test_preview_uses_live_context(seeded_app): + """Preview should include live table data from context.""" + c = seeded_app["client"] + admin = _auth(seeded_app["admin_token"]) + r = c.post( + "/api/admin/workspace-prompt-template/preview", + json={"content": "tables: {{ tables | length }}, metrics: {{ metrics.count }}"}, + headers=admin, + ) + assert r.status_code == 200 + # Content must be a rendered string (not raise), numbers may be 0 on fresh DB + assert "tables:" in r.json()["content"] + + +# --------------------------------------------------------------------------- +# Validation stub vs. build_claude_md_context shape alignment +# --------------------------------------------------------------------------- + +def test_validation_stub_matches_build_context_shape(seeded_app, tmp_path, monkeypatch): + """_VALIDATION_STUB_CONTEXT top-level keys must match build_claude_md_context() output.""" + from app.api.claude_md import _VALIDATION_STUB_CONTEXT + from src.db import _ensure_schema, get_system_db + import duckdb + + db_path = tmp_path / "system.duckdb" + c = duckdb.connect(str(db_path)) + _ensure_schema(c) + + user = { + "id": "u1", + "email": "admin@test.com", + "name": "Admin", + "is_admin": True, + "groups": ["Admin"], + } + from src.claude_md import build_claude_md_context + real_ctx = build_claude_md_context(c, user=user, server_url="https://example.com") + + assert set(_VALIDATION_STUB_CONTEXT.keys()) == set(real_ctx.keys()), ( + f"_VALIDATION_STUB_CONTEXT top-level keys differ from build_claude_md_context output. " + f"Stub: {set(_VALIDATION_STUB_CONTEXT.keys())}, " + f"real: {set(real_ctx.keys())}" + ) + c.close() diff --git a/tests/test_welcome_template_api.py b/tests/test_welcome_template_api.py index c3f9fda..7320ee3 100644 --- a/tests/test_welcome_template_api.py +++ b/tests/test_welcome_template_api.py @@ -1,7 +1,7 @@ """End-to-end tests for /api/admin/welcome-template (banner editor endpoints). -GET /api/welcome has been removed — the analyst-facing endpoint is gone. -These tests cover only the admin CRUD + preview endpoints. +These tests cover the admin CRUD + preview endpoints for the Agent Setup Prompt. +GET /api/welcome is handled by test_claude_md_api.py (Agent Workspace Prompt). """ import duckdb @@ -14,8 +14,8 @@ def _auth(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} -def test_get_welcome_endpoint_removed(seeded_app): - """GET /api/welcome must return 404 — the endpoint was deleted.""" +def test_get_welcome_endpoint_exists(seeded_app): + """GET /api/welcome must return 200 for authenticated analysts (endpoint restored).""" c = seeded_app["client"] token = seeded_app["analyst_token"] resp = c.get( @@ -23,7 +23,8 @@ def test_get_welcome_endpoint_removed(seeded_app): params={"server_url": "https://example.com"}, headers=_auth(token), ) - assert resp.status_code == 404 + assert resp.status_code == 200 + assert "content" in resp.json() def test_admin_get_template_initially_null(seeded_app):