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:
parent
f01eb4143d
commit
955b56608d
10 changed files with 1478 additions and 16 deletions
206
app/api/claude_md.py
Normal file
206
app/api/claude_md.py
Normal 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)
|
||||
|
|
@ -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/*
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{% if session.user.is_admin %}
|
||||
<a class="app-nav-link {% if _path.startswith('/admin/marketplaces') %}is-active{% endif %}" href="/admin/marketplaces">Marketplaces</a>
|
||||
{% set _admin_active = _path.startswith('/admin/tables') or _path.startswith('/admin/tokens') or _path.startswith('/admin/users') or _path.startswith('/admin/groups') or _path.startswith('/admin/access') or _path.startswith('/admin/server-config') or _path.startswith('/admin/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">
|
||||
<button type="button"
|
||||
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/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/workspace-prompt') %}is-active{% endif %}" role="menuitem" href="/admin/workspace-prompt">Agent Workspace Prompt</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
|||
529
app/web/templates/admin_workspace_prompt.html
Normal file
529
app/web/templates/admin_workspace_prompt.html
Normal 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 %}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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``<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": {
|
||||
"get": {
|
||||
"operationId": "admin_list_tokens_auth_admin_tokens_get",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
257
tests/test_claude_md_api.py
Normal file
257
tests/test_claude_md_api.py
Normal 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()
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue