feat(api,web,cli): /admin/workspace-prompt + /api/welcome restored + da analyst writes CLAUDE.md

- 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
This commit is contained in:
ZdenekSrotyr 2026-05-03 22:44:14 +02:00
parent f01eb4143d
commit 955b56608d
10 changed files with 1478 additions and 16 deletions

206
app/api/claude_md.py Normal file
View file

@ -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
``<workspace>/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)

View file

@ -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.v2_scan import router as v2_scan_router
from app.api.marketplaces import router as marketplaces_router from app.api.marketplaces import router as marketplaces_router
from app.api.welcome import router as welcome_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.router import router as marketplace_server_router
from app.marketplace_server.git_router import make_git_wsgi_app from app.marketplace_server.git_router import make_git_wsgi_app
from app.web.router import router as web_router 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(v2_scan_router)
app.include_router(marketplaces_router) app.include_router(marketplaces_router)
app.include_router(welcome_router) app.include_router(welcome_router)
app.include_router(claude_md_router)
app.include_router(marketplace_server_router) app.include_router(marketplace_server_router)
# Git smart-HTTP endpoint for Claude Code: /marketplace.git/* # Git smart-HTTP endpoint for Claude Code: /marketplace.git/*

View file

@ -950,6 +950,30 @@ async def admin_agent_prompt_page(
return templates.TemplateResponse(request, "admin_welcome.html", ctx) 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) @router.get("/tokens", response_class=HTMLResponse)
async def my_tokens_page( async def my_tokens_page(

View file

@ -14,7 +14,7 @@
<a class="app-nav-link {% if _path.startswith('/setup') or _path.startswith('/install') %}is-active{% endif %}" href="/setup">Setup local agent</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 %} {% if session.user.is_admin %}
<a class="app-nav-link {% if _path.startswith('/admin/marketplaces') %}is-active{% endif %}" href="/admin/marketplaces">Marketplaces</a> <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/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') %}
<div class="app-nav-menu" id="adminNavMenu"> <div class="app-nav-menu" id="adminNavMenu">
<button type="button" <button type="button"
class="app-nav-link app-nav-menu-trigger {% if _admin_active %}is-active{% endif %}" class="app-nav-link app-nav-menu-trigger {% if _admin_active %}is-active{% endif %}"
@ -33,6 +33,7 @@
<a class="app-nav-menu-item {% if _path.startswith('/admin/access') %}is-active{% endif %}" role="menuitem" href="/admin/access">Resource access</a> <a class="app-nav-menu-item {% if _path.startswith('/admin/access') %}is-active{% endif %}" role="menuitem" href="/admin/access">Resource access</a>
<a class="app-nav-menu-item {% if _path.startswith('/admin/server-config') %}is-active{% endif %}" role="menuitem" href="/admin/server-config">Server config</a> <a class="app-nav-menu-item {% if _path.startswith('/admin/server-config') %}is-active{% endif %}" role="menuitem" href="/admin/server-config">Server config</a>
<a class="app-nav-menu-item {% if _path.startswith('/admin/agent-prompt') %}is-active{% endif %}" role="menuitem" href="/admin/agent-prompt">Agent Setup Prompt</a> <a class="app-nav-menu-item {% if _path.startswith('/admin/agent-prompt') %}is-active{% endif %}" role="menuitem" href="/admin/agent-prompt">Agent Setup Prompt</a>
<a class="app-nav-menu-item {% if _path.startswith('/admin/workspace-prompt') %}is-active{% endif %}" role="menuitem" href="/admin/workspace-prompt">Agent Workspace Prompt</a>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View file

@ -0,0 +1,529 @@
{% extends "base.html" %}
{% block title %}Agent Workspace Prompt — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"
integrity="sha512-uf06llspW44/LZpHzHT6qBOIVODjWtv4MxCricRxkzvopAlSWnTf6hpZTFxuuZcuNE9CBQhqE0Seu1CoRk84nQ=="
crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"
integrity="sha512-2OhXH4Il3n2tHKwLLSDPhrkgnLBC+6lHGGQzSFi3chgVB6DJ/v6+nbx+XYO9CugQyHVF/8D/0k3Hx1eaUK2K9g=="
crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/markdown/markdown.min.css"
crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"
integrity="sha512-OeZ4Yrb/W7d2W4rAMOO0HQ9Ro/aWLtpW9BUSR2UOWnSV2hprXLkkYnnCGc9NeLUxxE4ZG7zN16UuT1Elqq8Opg=="
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/jinja2/jinja2.min.js"
integrity="sha512-Z4le1RxwhD8lDCrspbBxjTLLP2HGC1+mKb9KHR2N/sEx8uOe2vre5XQo8YMPAz8FQTo43HjefjlDtjY4LtfaaQ=="
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/markdown/markdown.min.js"
crossorigin="anonymous"></script>
<style>
.container:has(.welcome-page) { max-width: none; padding: 24px 16px; }
.welcome-page { max-width: 1400px; margin: 0 auto; padding: 0; }
.welcome-toolbar {
display: flex; justify-content: space-between; align-items: center;
gap: 16px; margin-bottom: 16px; flex-wrap: wrap;
}
.welcome-title { margin: 0; font-size: 22px; font-weight: 600; }
.welcome-sub { color: var(--text-secondary, #6b7280); font-size: 13px; margin-top: 4px; margin-bottom: 0; }
.origin-chip {
display: inline-block;
padding: 3px 10px; border-radius: 999px;
font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.4px;
}
.origin-override { background: #ede9fe; color: #6d28d9; }
.origin-default { background: #f3f4f6; color: #6b7280; }
.welcome-card {
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-radius: 12px;
margin-bottom: 16px;
overflow: hidden;
}
.welcome-card-body { padding: 20px 22px; }
.welcome-desc { font-size: 13px; color: var(--text-secondary, #6b7280); margin: 0 0 16px; }
/* Placeholder cheatsheet — collapsible */
details.welcome-cheatsheet { margin-bottom: 18px; }
details.welcome-cheatsheet summary {
cursor: pointer; font-size: 13px; font-weight: 500;
color: var(--text-primary, #111827); list-style: none;
display: inline-flex; align-items: center; gap: 6px;
}
details.welcome-cheatsheet summary::-webkit-details-marker { display: none; }
details.welcome-cheatsheet summary::before {
content: "▶"; font-size: 10px; color: var(--text-secondary, #6b7280);
transition: transform 0.15s;
}
details.welcome-cheatsheet[open] summary::before { transform: rotate(90deg); }
/* Dark code block */
.code-block {
background: #1e1e2e;
border-radius: 8px;
padding: 14px 16px;
display: flex;
align-items: flex-start;
gap: 12px;
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
font-size: 13px;
color: #cdd6f4;
line-height: 1.6;
margin-top: 10px;
position: relative;
}
.code-block .code-body { flex: 1; white-space: pre; overflow-x: auto; }
.btn-copy {
padding: 6px 14px;
background: transparent;
border: 1px solid #45475a;
color: #cdd6f4;
cursor: pointer;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
font-family: inherit;
transition: all 0.15s ease;
flex-shrink: 0;
}
.btn-copy:hover { border-color: #89b4fa; color: #89b4fa; background: rgba(137, 180, 250, 0.08); }
.btn-copy.copied { border-color: #a6e3a1; color: #a6e3a1; background: rgba(166, 227, 161, 0.08); }
/* CodeMirror editor styling */
.welcome-page .CodeMirror {
border: 1px solid var(--border, #e5e7eb);
border-radius: 8px;
font-size: 13px;
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
}
/* Split layout: editor left, preview right */
.welcome-editor-row {
display: flex;
gap: 16px;
margin-top: 0;
}
.welcome-pane {
flex: 1 1 50%;
min-width: 0;
display: flex;
flex-direction: column;
}
.welcome-pane-label {
color: var(--text-secondary, #6b7280);
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.welcome-editor-col, .welcome-preview-col {
flex: 1 1 auto;
min-height: 480px;
height: calc(100vh - 360px);
display: flex;
flex-direction: column;
}
.welcome-preview-col {
border: 1px solid var(--border, #e5e7eb);
border-radius: 8px;
background: var(--surface, #fff);
color: var(--text-primary, #111827);
padding: 16px;
font-family: var(--font-primary, system-ui, sans-serif);
font-size: 14px;
overflow: auto;
}
.welcome-preview-error {
background: rgba(234, 88, 12, 0.15);
color: #fca5a5;
border: 1px solid rgba(234, 88, 12, 0.4);
border-radius: 6px;
padding: 10px 12px;
font-size: 12px;
white-space: pre-wrap;
}
@media (max-width: 1100px) {
.welcome-editor-row { flex-direction: column; }
}
/* Action row */
.welcome-actions {
padding: 14px 22px;
background: var(--border-light, #fafafa);
border-top: 1px solid var(--border, #e5e7eb);
display: flex; gap: 8px; justify-content: flex-end;
}
.welcome-btn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
cursor: pointer; transition: all 0.15s;
}
.welcome-btn:hover { background: var(--border-light, #f9fafb); }
.welcome-btn.primary { background: var(--primary, #6366f1); color: #fff; border-color: var(--primary, #6366f1); }
.welcome-btn.primary:hover { filter: brightness(1.05); }
.welcome-btn.danger { background: #dc2626; color: #fff; border-color: #dc2626; }
.welcome-btn.danger:hover { filter: brightness(1.05); }
.welcome-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Modal */
.modal-backdrop {
position: fixed; inset: 0; background: rgba(15, 23, 42, 0.55);
display: none; align-items: center; justify-content: center; z-index: 1000;
padding: 16px;
}
.modal-backdrop.is-open { display: flex; }
.modal-card {
background: var(--surface, #fff); border-radius: 12px;
padding: 24px; width: 100%; max-width: 440px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
}
.modal-card h3 { margin: 0 0 6px; font-size: 17px; font-weight: 600; }
.modal-card p.sub { margin: 0 0 18px; font-size: 13px; color: var(--text-secondary, #6b7280); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
.modal-btn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
cursor: pointer;
}
.modal-btn.danger { background: #dc2626; color: #fff; border-color: #dc2626; }
/* Toast stack */
.toast-stack {
position: fixed; bottom: 24px; right: 24px; z-index: 2000;
display: flex; flex-direction: column; gap: 8px; pointer-events: none;
}
.toast {
background: #111827; color: #fff; padding: 10px 16px;
border-radius: 8px; font-size: 13px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
opacity: 0; transform: translateY(8px); transition: opacity 0.2s, transform 0.2s;
pointer-events: auto; max-width: 380px;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.success { background: #047857; }
.toast.error { background: #b91c1c; }
</style>
<div class="welcome-page">
<div class="welcome-toolbar">
<div>
<h2 class="welcome-title">Agent Workspace Prompt</h2>
<p class="welcome-sub">Customize the <code>CLAUDE.md</code> Claude Code reads when it opens the analyst workspace.</p>
</div>
<div id="status-chip">
{% if is_override %}
<span class="origin-chip origin-override"
title="Overridden by {{ updated_by }} on {{ updated_at.strftime('%Y-%m-%d %H:%M UTC') if updated_at else '—' }}">
Override active
</span>
{% else %}
<span class="origin-chip origin-default">Using default</span>
{% endif %}
</div>
</div>
<div class="welcome-card">
<div class="welcome-card-body">
<p class="welcome-desc">
<strong>Default:</strong> a rich markdown briefing about Agnes commands, registered tables
(RBAC-filtered for the calling analyst), available metrics, and marketplace plugins.
Written to <code>CLAUDE.md</code> in the analyst workspace at <code>da analyst setup</code> time.
Use <code>--no-claude-md</code> to skip writing it.
</p>
<p class="welcome-desc">
<strong>Template engine:</strong> Jinja2 with <code>StrictUndefined</code> — unknown placeholders
raise an error at save time. Use <code>{{ "{% if user.is_admin %}" }}…{{ "{% endif %}" }}</code>
to guard admin-only context.
</p>
<details class="welcome-cheatsheet">
<summary>Available Jinja2 placeholders</summary>
<div class="code-block">
<span id="placeholder-text" class="code-body">{{ "{{ 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"</span>
<button class="btn-copy" data-copy-target="placeholder-text">Copy</button>
</div>
</details>
<div class="welcome-editor-row">
<div class="welcome-pane">
<h4 class="welcome-pane-label">Editor</h4>
<div class="welcome-editor-col">
<textarea id="content" name="content">{{ current or default_template }}</textarea>
</div>
</div>
<div class="welcome-pane">
<h4 class="welcome-pane-label">Live preview</h4>
<div class="welcome-preview-col">
<pre id="preview-content" style="white-space: pre-wrap; word-break: break-word; font-family: var(--font-mono, monospace); font-size: 12px; margin: 0;">(rendering…)</pre>
<div id="preview-error" class="welcome-preview-error" hidden></div>
</div>
</div>
</div>
</div>
<div class="welcome-actions">
<button type="button" class="welcome-btn" id="reset-btn">Reset to default</button>
<button type="button" class="welcome-btn primary" id="save-btn">Save override</button>
</div>
</div>
</div>
<!-- Reset confirmation modal -->
<div class="modal-backdrop" id="reset-modal" role="dialog" aria-modal="true" aria-labelledby="reset-modal-title">
<div class="modal-card">
<h3 id="reset-modal-title">Reset to default?</h3>
<p class="sub">Your override will be permanently removed. The rich default CLAUDE.md briefing will be written to analyst workspaces instead. This cannot be undone.</p>
<div class="modal-actions">
<button class="modal-btn" data-close-modal="reset-modal">Cancel</button>
<button class="modal-btn danger" id="reset-confirm-btn">Reset</button>
</div>
</div>
</div>
<div class="toast-stack" id="toast-stack" aria-live="polite"></div>
<script>
const API = "/api/admin/workspace-prompt-template";
// ── CodeMirror editor (with graceful CDN fallback) ────────────────────
const ta = document.getElementById("content");
if (typeof CodeMirror === "undefined") {
// CDN unreachable or SRI mismatch — degrade to plain textarea + warn.
ta.style.display = "block";
ta.style.width = "100%";
ta.style.minHeight = "480px";
ta.style.fontFamily = "var(--font-mono)";
ta.style.fontSize = "13px";
ta.style.padding = "9px 12px";
ta.style.border = "1px solid var(--border)";
ta.style.borderRadius = "8px";
// Polyfill the `editor` interface so save/reset/preview still work.
window.editor = {
getValue: () => ta.value,
setValue: (v) => { ta.value = v; },
on: () => {},
setSize: () => {},
};
setTimeout(() => toast("Code editor failed to load — using plain textarea. Check network/CSP.", "error"), 0);
} else {
// Normal path — jinja2 mode works well for Jinja2-in-markdown
window.editor = CodeMirror.fromTextArea(ta, {
mode: "jinja2",
lineNumbers: true,
lineWrapping: true,
theme: "material-darker",
indentUnit: 2,
tabSize: 2,
});
editor.setSize("100%", "100%");
}
// ── Live preview (debounced) ──────────────────────────────────────────
let previewTimer = null;
function schedulePreview() {
if (previewTimer) clearTimeout(previewTimer);
previewTimer = setTimeout(renderPreview, 500);
}
async function renderPreview() {
const content = editor.getValue();
const previewBox = document.getElementById("preview-content");
const previewErr = document.getElementById("preview-error");
if (!content.trim()) {
previewBox.textContent = "(empty)";
previewErr.hidden = true;
return;
}
try {
const r = await fetch(API + "/preview", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (r.ok) {
const j = await r.json();
// Use textContent (not innerHTML) — content is markdown, not trusted HTML
previewBox.textContent = j.content;
previewErr.hidden = true;
} else {
let detail = r.statusText;
try { detail = (await r.json()).detail || detail; } catch (_) {}
previewBox.textContent = "";
previewErr.textContent = detail;
previewErr.hidden = false;
}
} catch (e) {
previewErr.textContent = "Network error: " + e.message;
previewErr.hidden = false;
}
}
editor.on("change", schedulePreview);
renderPreview(); // initial render on load
// ── Toast ─────────────────────────────────────────────────────────────
function toast(msg, kind) {
const el = document.createElement("div");
el.className = "toast" + (kind ? " " + kind : "");
el.textContent = msg;
document.getElementById("toast-stack").appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => { el.classList.remove("show"); setTimeout(() => el.remove(), 250); }, 3500);
}
// ── Modal helpers ─────────────────────────────────────────────────────
function openModal(id) { document.getElementById(id).classList.add("is-open"); }
function closeModal(id) { document.getElementById(id).classList.remove("is-open"); }
document.querySelectorAll("[data-close-modal]").forEach(el =>
el.addEventListener("click", () => closeModal(el.dataset.closeModal)));
document.querySelectorAll(".modal-backdrop").forEach(el => {
el.addEventListener("click", e => { if (e.target === el) el.classList.remove("is-open"); });
});
document.addEventListener("keydown", e => {
if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.is-open").forEach(m => m.classList.remove("is-open"));
});
// ── Status chip ───────────────────────────────────────────────────────
function setStatusChip(data) {
const wrap = document.getElementById("status-chip");
if (data.content !== null) {
const when = data.updated_at
? new Date(data.updated_at).toISOString().slice(0, 16).replace("T", " ") + " UTC"
: "—";
const who = data.updated_by || "—";
wrap.innerHTML = `<span class="origin-chip origin-override" title="Overridden by ${who} on ${when}">Override active</span>`;
} else {
wrap.innerHTML = `<span class="origin-chip origin-default">Using default</span>`;
}
}
// ── Refresh status + editor content from API ─────────────────────────
async function refreshStatus() {
const r = await fetch(API, { credentials: "include" });
if (!r.ok) return;
const data = await r.json();
setStatusChip(data);
// When an override is set, show it; otherwise show the live default so the
// editor is never empty and admins can see what they're overriding.
editor.setValue(data.content !== null ? data.content : (data.default || ""));
renderPreview();
}
// ── Save ──────────────────────────────────────────────────────────────
document.getElementById("save-btn").addEventListener("click", async () => {
const btn = document.getElementById("save-btn");
btn.disabled = true;
try {
const r = await fetch(API, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: editor.getValue() }),
});
if (r.ok) {
toast("Override saved.", "success");
await refreshStatus();
} else {
let detail = r.statusText;
try { detail = (await r.json()).detail || detail; } catch (_) {}
toast("Save failed: " + detail, "error");
}
} catch (e) {
toast("Save failed: " + e.message, "error");
} finally {
btn.disabled = false;
}
});
// ── Reset (with confirm modal) ────────────────────────────────────────
document.getElementById("reset-btn").addEventListener("click", () => openModal("reset-modal"));
document.getElementById("reset-confirm-btn").addEventListener("click", async () => {
closeModal("reset-modal");
const btn = document.getElementById("reset-btn");
btn.disabled = true;
try {
const r = await fetch(API, { method: "DELETE", credentials: "include" });
if (r.ok) {
toast("Reset to default.", "success");
await refreshStatus();
} else {
let detail = r.statusText;
try { detail = (await r.json()).detail || detail; } catch (_) {}
toast("Reset failed: " + detail, "error");
}
} catch (e) {
toast("Reset failed: " + e.message, "error");
} finally {
btn.disabled = false;
}
});
// ── Copy widget ───────────────────────────────────────────────────────
function copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
const ta = document.createElement("textarea");
ta.value = text;
ta.style.cssText = "position:fixed;left:-9999px;top:-9999px";
document.body.appendChild(ta);
ta.focus();
ta.select();
return new Promise((resolve, reject) => {
try { document.execCommand("copy") ? resolve() : reject(); }
finally { document.body.removeChild(ta); }
});
}
function flashCopied(button) {
const original = button.textContent;
button.textContent = "Copied!";
button.classList.add("copied");
setTimeout(() => { button.textContent = original; button.classList.remove("copied"); }, 1500);
}
document.querySelectorAll(".btn-copy[data-copy-target]").forEach(btn => {
btn.addEventListener("click", () => {
const target = document.getElementById(btn.getAttribute("data-copy-target"));
if (!target) return;
copyToClipboard(target.textContent.trim())
.then(() => flashCopied(btn))
.catch(() => {
const range = document.createRange();
range.selectNodeContents(target);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
});
});
});
</script>
{% endblock %}

View file

@ -297,11 +297,15 @@ def _install_claude_hooks(settings_path: Path) -> None:
# Helper: initialise Claude workspace (.claude/ directory) # 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. """Initialise the .claude/ directory with placeholder files and hooks.
Does NOT write CLAUDE.md workspace-context customisation is handled Writes CLAUDE.md from the server (GET /api/welcome) unless ``server_url``
server-side via the banner on /setup, not as a file in the workspace. or ``token`` are empty, or the request fails (graceful degradation).
""" """
local_md = workspace / ".claude" / "CLAUDE.local.md" local_md = workspace / ".claude" / "CLAUDE.local.md"
if not local_md.exists(): if not local_md.exists():
@ -320,6 +324,57 @@ def _init_claude_workspace(workspace: Path) -> None:
_install_claude_hooks(settings_path) _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) # 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"), 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"), 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)"), 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.""" """Bootstrap a new analyst workspace from a remote server."""
workspace = Path(workspace_dir).resolve() if workspace_dir else Path.cwd() workspace = Path(workspace_dir).resolve() if workspace_dir else Path.cwd()
@ -385,9 +441,13 @@ def setup(
typer.echo("Initialising DuckDB views...") typer.echo("Initialising DuckDB views...")
total_rows = _initialize_duckdb(workspace) 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...") 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 # 8. Summary
typer.echo("") typer.echo("")
@ -396,6 +456,8 @@ def setup(
typer.echo(f" Tables : {n_downloaded} downloaded, {total_rows} total rows") typer.echo(f" Tables : {n_downloaded} downloaded, {total_rows} total rows")
typer.echo(f" Workspace: {workspace}") typer.echo(f" Workspace: {workspace}")
typer.echo(f" Hooks : SessionStart/End installed in {workspace}/.claude/settings.json") 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("")
typer.echo("Next steps:") typer.echo("Next steps:")
typer.echo(" da sync — refresh data") typer.echo(" da sync — refresh data")

View file

@ -397,6 +397,19 @@
"title": "BulkUpdateRequest", "title": "BulkUpdateRequest",
"type": "object" "type": "object"
}, },
"ClaudeMdResponse": {
"properties": {
"content": {
"title": "Content",
"type": "string"
}
},
"required": [
"content"
],
"title": "ClaudeMdResponse",
"type": "object"
},
"ColumnMetadataItem": { "ColumnMetadataItem": {
"properties": { "properties": {
"basetype": { "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": { "/api/admin/access-overview": {
"get": { "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``.", "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}": { "/api/catalog/metrics/{metric_path}": {
"get": { "get": {
"deprecated": true, "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``<workspace>/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": { "/auth/admin/tokens": {
"get": { "get": {
"operationId": "admin_list_tokens_auth_admin_tokens_get", "operationId": "admin_list_tokens_auth_admin_tokens_get",

View file

@ -139,20 +139,65 @@ class TestCreateWorkspace:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestInitClaudeWorkspace: class TestInitClaudeWorkspace:
"""Tests for _init_claude_workspace: no CLAUDE.md written, but """Tests for _init_claude_workspace."""
.claude/CLAUDE.local.md placeholder and settings.json hooks are created.
"""
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 from cli.commands.analyst import _create_workspace, _init_claude_workspace
_create_workspace(tmp_workspace) _create_workspace(tmp_workspace)
_init_claude_workspace(tmp_workspace) _init_claude_workspace(tmp_workspace)
assert not (tmp_workspace / "CLAUDE.md").exists(), ( 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): def test_creates_claude_local_md_when_absent(self, tmp_workspace):
from cli.commands.analyst import _create_workspace, _init_claude_workspace from cli.commands.analyst import _create_workspace, _init_claude_workspace

257
tests/test_claude_md_api.py Normal file
View file

@ -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()

View file

@ -1,7 +1,7 @@
"""End-to-end tests for /api/admin/welcome-template (banner editor endpoints). """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 the admin CRUD + preview endpoints for the Agent Setup Prompt.
These tests cover only the admin CRUD + preview endpoints. GET /api/welcome is handled by test_claude_md_api.py (Agent Workspace Prompt).
""" """
import duckdb import duckdb
@ -14,8 +14,8 @@ def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}"}
def test_get_welcome_endpoint_removed(seeded_app): def test_get_welcome_endpoint_exists(seeded_app):
"""GET /api/welcome must return 404 — the endpoint was deleted.""" """GET /api/welcome must return 200 for authenticated analysts (endpoint restored)."""
c = seeded_app["client"] c = seeded_app["client"]
token = seeded_app["analyst_token"] token = seeded_app["analyst_token"]
resp = c.get( resp = c.get(
@ -23,7 +23,8 @@ def test_get_welcome_endpoint_removed(seeded_app):
params={"server_url": "https://example.com"}, params={"server_url": "https://example.com"},
headers=_auth(token), 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): def test_admin_get_template_initially_null(seeded_app):