Merge pull request #169 from keboola/zs/agent-workspace-prompt
feat: Agent Setup Prompt + Workspace Prompt — admin-editable bash setup script + CLAUDE.md (combined)
This commit is contained in:
commit
c0aa278c67
20 changed files with 2450 additions and 26 deletions
|
|
@ -10,18 +10,19 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.31.0] — 2026-05-03
|
||||
## [0.31.0] — 2026-05-04
|
||||
|
||||
### Added
|
||||
|
||||
- **Agent Workspace Prompt** — admin-editable Jinja2 markdown template for the analyst's `CLAUDE.md`, surfaced in their workspace by `da analyst setup`. Default = rich briefing with RBAC-filtered tables/metrics/marketplaces context. Edit at `/admin/workspace-prompt`. Endpoints: `GET /api/welcome` (analyst-facing, auth required), `GET/PUT/DELETE /api/admin/workspace-prompt-template`, `POST /api/admin/workspace-prompt-template/preview`. CLI: `da analyst setup` writes `CLAUDE.md` by default; new `--no-claude-md` flag opts out. See `docs/agent-workspace-prompt.md`.
|
||||
- **Agent Setup Prompt** — customizable bash setup script shown on `/setup` and copied by the dashboard clipboard CTA. Default = the live `setup_instructions.resolve_lines()` output (TLS trust bootstrap, CLI install, login, marketplace, skills). Admin override at `/admin/agent-prompt` — full replacement of the default, not a banner added on top. Override flows to both the `/setup` page display and the dashboard clipboard payload. Jinja2 is available for `{{ instance.name }}` etc.; `{server_url}` and `{token}` are JS-substituted at clipboard-copy time and survive Jinja2 rendering unchanged. REST API: `GET /api/admin/welcome-template` returns `{content, default, updated_at, updated_by}` (`content` is `null` when no override is set; `default` is always the live computed script); `PUT` to set an override; `DELETE` to clear; `POST /api/admin/welcome-template/preview` for live preview without persisting. Available Jinja2 placeholders: `instance.{name,subtitle}`, `server.{url,hostname}`, `user` (may be `null` for anonymous visitors), `now`, `today`. Override content is HTML-sanitized post-render (script/iframe/event-handler strip). See `docs/agent-setup-prompt.md`.
|
||||
- DuckDB schema v21: `welcome_template` singleton table backing the banner override. Auto-migration v20→v21 on first start.
|
||||
- DuckDB schema v21: `welcome_template` singleton table backing the Agent Setup Prompt override. Auto-migration v20→v21 on first start.
|
||||
- DuckDB schema v22: `setup_banner` table reserved (no consumers; retained for forward compatibility with already-migrated instances).
|
||||
- DuckDB schema v23: `claude_md_template` singleton table backing the Agent Workspace Prompt override. Auto-migration v22→v23.
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING (CLI):** `da analyst setup` no longer generates a `CLAUDE.md` file in the analyst workspace. Workspace-context customisation is handled via the `/setup` page banner instead. Existing analysts with a server-generated `CLAUDE.md` may delete it manually if desired.
|
||||
- **BREAKING (API):** `GET /api/welcome` removed. The endpoint was internal-only (consumed only by the CLI's now-removed `CLAUDE.md` generation step).
|
||||
- `da analyst setup` writes `CLAUDE.md` to the analyst workspace from the server-rendered template (fetched via `GET /api/welcome`). Use `--no-claude-md` to opt out. Analysts who ran setup while CLAUDE.md generation was temporarily absent will have their file written on the next `da analyst setup` run.
|
||||
- `/install` page renamed to `/setup` ("Setup local agent" nav label) with 302 redirect from `/install`.
|
||||
- Dashboard "What Claude Code will receive" inline preview replaced with a link to `/setup` for the canonical view.
|
||||
|
||||
|
|
|
|||
|
|
@ -430,7 +430,7 @@ Module sets `lifecycle { ignore_changes = [metadata_startup_script] }` on `googl
|
|||
## Key Implementation Details
|
||||
|
||||
### DuckDB Schema (src/db.py)
|
||||
- Schema v22 with auto-migration v1→…→v22 (v5 adds `users.active`, v6 adds `personal_access_tokens`, v7 adds `personal_access_tokens.last_used_ip`, v8/v9 added the legacy internal_roles/role-grants tables, v10 added `view_ownership` for cross-connector view-name collision detection (issue #81 Group C), v11 added marketplace_registry + marketplace_plugins + user_groups + plugin_access, v12 added users.groups JSON + user_groups.is_system, **v13 replaces internal_roles/group_mappings/user_role_grants/plugin_access with user_group_members + resource_grants and drops users.groups JSON**, v14 adds FK constraints on user_group_members + resource_grants after orphan cleanup, v15 adds knowledge_items context-engineering columns + contradictions + session_extraction_state, v16 adds verification_evidence, v17 adds knowledge_item_relations, v18 drops stranded non-google memberships from google-managed groups, **v19 drops legacy `dataset_permissions`, `access_requests` tables and `users.role`, `table_registry.is_public` columns — table access is now exclusively per-group via `resource_grants(resource_type='table')`**, **v20 adds `source_query` TEXT to `table_registry` to back `query_mode='materialized'` (BigQuery scheduled-query parquet path)**, **v21 adds `welcome_template` singleton table backing the Agent Setup Prompt admin override (`/admin/agent-prompt`)**, **v22 reserves the `setup_banner` table — feature dropped mid-development; table retained for forward compatibility with already-migrated instances** — see CHANGELOG and docs/RBAC.md)
|
||||
- Schema v23 with auto-migration v1→…→v23 (v5 adds `users.active`, v6 adds `personal_access_tokens`, v7 adds `personal_access_tokens.last_used_ip`, v8/v9 added the legacy internal_roles/role-grants tables, v10 added `view_ownership` for cross-connector view-name collision detection (issue #81 Group C), v11 added marketplace_registry + marketplace_plugins + user_groups + plugin_access, v12 added users.groups JSON + user_groups.is_system, **v13 replaces internal_roles/group_mappings/user_role_grants/plugin_access with user_group_members + resource_grants and drops users.groups JSON**, v14 adds FK constraints on user_group_members + resource_grants after orphan cleanup, v15 adds knowledge_items context-engineering columns + contradictions + session_extraction_state, v16 adds verification_evidence, v17 adds knowledge_item_relations, v18 drops stranded non-google memberships from google-managed groups, **v19 drops legacy `dataset_permissions`, `access_requests` tables and `users.role`, `table_registry.is_public` columns — table access is now exclusively per-group via `resource_grants(resource_type='table')`**, **v20 adds `source_query` TEXT to `table_registry` to back `query_mode='materialized'` (BigQuery scheduled-query parquet path)**, **v21 adds `welcome_template` singleton table backing the Agent Setup Prompt admin override (`/admin/agent-prompt`)**, **v22 reserves the `setup_banner` table — feature dropped mid-development; table retained for forward compatibility with already-migrated instances**, **v23 adds `claude_md_template` singleton table backing the Agent Workspace Prompt admin override (`/admin/workspace-prompt`)** — see CHANGELOG and docs/RBAC.md)
|
||||
- `table_registry`: id, name, source_type, bucket, source_table, query_mode, sync_schedule, etc.
|
||||
- `sync_state`, `sync_history`: track extraction progress
|
||||
- `users`, `audit_log`: account state + audit trail. RBAC lives in `user_groups` + `user_group_members` + `resource_grants`.
|
||||
|
|
|
|||
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")
|
||||
|
|
|
|||
195
config/claude_md_template.txt
Normal file
195
config/claude_md_template.txt
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
{# Default analyst-onboarding workspace prompt for "da analyst setup".
|
||||
Rendered server-side by src/claude_md.py. Edit this file to change
|
||||
the OSS default; admins override per-instance via /admin/workspace-prompt.
|
||||
|
||||
Available context (see docs/agent-workspace-prompt.md for the full reference):
|
||||
instance.name, instance.subtitle
|
||||
server.url, server.hostname
|
||||
sync_interval — string from instance.yaml
|
||||
data_source.type — keboola | bigquery | local
|
||||
tables — list of {name, description, query_mode}
|
||||
metrics.count, metrics.categories
|
||||
marketplaces — list of {slug, name, plugins:[{name}]}
|
||||
user.id, user.email, user.name, user.is_admin, user.groups
|
||||
now, today — datetime / date string
|
||||
#}
|
||||
# {{ instance.name }} — AI Data Analyst
|
||||
|
||||
This workspace is connected to {{ server.url }}.
|
||||
{% if instance.subtitle %}Operated by **{{ instance.subtitle }}**.{% endif %}
|
||||
|
||||
## Rules
|
||||
- Before computing any business metric: run `da metrics show <category>/<name>`
|
||||
- **For canonical table list with query modes: `da catalog`.** `data/metadata/schema.json` covers `query_mode: "local"` tables only — for remote/hybrid tables it's incomplete. Treat `da catalog` as source of truth.
|
||||
- Do not use DESCRIBE/SHOW COLUMNS — use `da schema <table>` instead
|
||||
- Save work output to `user/artifacts/`
|
||||
- Sync data regularly with `da sync`
|
||||
- **Personal customizations go in `.claude/CLAUDE.local.md`, NOT here.** This file is regenerated by `da analyst setup --force`; edits here will be lost. CLAUDE.local.md is preserved across regeneration and uploaded on `da sync --upload-only`.
|
||||
|
||||
## Metrics Workflow
|
||||
1. `da metrics list` — find the relevant metric ({{ metrics.count }} available, categories: {{ metrics.categories | join(", ") or "none yet" }})
|
||||
2. `da metrics show <category>/<name>` — read SQL and business rules
|
||||
3. Use the canonical SQL from the metric definition, adapt to the question
|
||||
4. Never invent metric calculations — always check existing definitions first
|
||||
|
||||
## Data Sync
|
||||
- `da sync` — download current data from server
|
||||
- `da sync --docs-only` — just metadata and metrics (fast refresh)
|
||||
- `da sync --upload-only` — upload sessions and local notes to server
|
||||
- Data on the server refreshes every {{ sync_interval }}
|
||||
|
||||
## Available Datasets
|
||||
{% for t in tables -%}
|
||||
- `{{ t.name }}`{% if t.description %} — {{ t.description }}{% endif %}{% if t.query_mode == "remote" %} *(remote, queried on demand)*{% endif %}
|
||||
{% else -%}
|
||||
- _No tables registered yet — ask an admin to register tables in the dashboard._
|
||||
{% endfor %}
|
||||
|
||||
{% if marketplaces -%}
|
||||
## Plugins available to you
|
||||
{% for mp in marketplaces -%}
|
||||
- **{{ mp.name }}** ({{ mp.slug }}): {{ mp.plugins | map(attribute="name") | join(", ") }}
|
||||
{% endfor %}
|
||||
{% endif -%}
|
||||
|
||||
## Remote Queries (BigQuery) — when data isn't on the laptop
|
||||
|
||||
Not every table is synced. Tables registered with `query_mode: "remote"` live in
|
||||
BigQuery, accessed server-side via DuckDB's BQ extension — no parquet on disk.
|
||||
Tables you don't see in `data/parquet/` may still be queryable.
|
||||
|
||||
### Discovery first
|
||||
|
||||
```
|
||||
da catalog --json | jq '.[] | {name, source_type, query_mode}' # see all tables + their modes
|
||||
da schema <table> # columns + types
|
||||
da describe <table> -n 5 # sample rows
|
||||
```
|
||||
|
||||
For local-mode tables, query directly with `da query "SELECT … FROM <table>"`.
|
||||
|
||||
### Three patterns for `query_mode: "remote"` tables
|
||||
|
||||
| Pattern | Tool | Use when |
|
||||
|---------|------|----------|
|
||||
| **`da fetch`** (preferred) | materializes a filtered subset locally → query the snapshot | repeated questions on same slice |
|
||||
| **`da query --remote`** | one-shot, server-side execution against BigQuery (works for BASE TABLE rows directly + VIEW/MATERIALIZED_VIEW rows via the BQ jobs API; cost-guarded by a 5 GiB scan cap configurable in /admin/server-config) | single aggregate / cheap probe |
|
||||
| **`da query --register-bq`** | hybrid joins between local snapshots and ad-hoc BQ subqueries | crossing local + remote |
|
||||
|
||||
### Permission model + cost — important
|
||||
|
||||
- BQ access goes through the **agnes server's GCE service account**, not your personal Google credentials. If a query fails with a permission error, the table is in a project the server SA cannot read — escalate to admin, do NOT try to authenticate yourself.
|
||||
- Every BQ query bills the SA's GCP project for **bytes scanned**. A naive `SELECT * FROM <large_table>` can cost real money. ALWAYS:
|
||||
- filter via `--where` on the partition column (typically a date)
|
||||
- list specific columns in `--select` — column-store BQ skips the rest, cheaper
|
||||
- run `--estimate` first when unsure of the table size or partitioning
|
||||
|
||||
### `da fetch` discipline
|
||||
|
||||
```
|
||||
# 1. ESTIMATE first — refuses to fetch without knowing the cost
|
||||
da fetch <table> --select col1,col2 --where "date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)" --estimate
|
||||
|
||||
# 2. If reasonable, fetch as a named snapshot
|
||||
da fetch <table> --select col1,col2 --where "..." --as my_recent
|
||||
|
||||
# 3. Query the local snapshot
|
||||
da query "SELECT col1, COUNT(*) FROM my_recent GROUP BY 1"
|
||||
|
||||
# 4. List + drop snapshots when done
|
||||
da snapshot list
|
||||
da snapshot drop my_recent
|
||||
```
|
||||
|
||||
Rules of thumb:
|
||||
- ALWAYS list specific columns in `--select`. Avoid implicit SELECT *.
|
||||
- ALWAYS include a `--where` for remote tables; otherwise add `--limit`.
|
||||
- ALWAYS run `--estimate` first when the table is `partition_by` / `clustered_by`
|
||||
per `da schema`, or could plausibly exceed 1 GB local bytes.
|
||||
- Reuse snapshots across questions in the same conversation — `da snapshot list`
|
||||
before fetching.
|
||||
|
||||
### Snapshot freshness — when to refresh
|
||||
|
||||
Snapshots are point-in-time copies. They go stale as the source data updates (most BQ tables refresh daily; check `sync_schedule` per `da catalog`). For each new conversation:
|
||||
|
||||
```
|
||||
da snapshot list # see existing snapshots + their ages
|
||||
da snapshot drop my_recent # drop stale ones
|
||||
da fetch <table> --select ... --where ... --as my_recent # re-fetch
|
||||
```
|
||||
|
||||
If the question is time-sensitive (e.g. "today's orders"), assume any snapshot older than the table's `sync_schedule` is stale and refresh.
|
||||
|
||||
### Hybrid query example — local + remote in one query
|
||||
|
||||
`da query --register-bq` lets a single SQL statement join a local table with an ad-hoc BQ subquery. The BQ subquery runs first (server-side), result registered as a DuckDB view, then the joined query runs locally.
|
||||
|
||||
```
|
||||
da query \
|
||||
--register-bq "traffic=SELECT date, country, SUM(views) AS views \
|
||||
FROM \`prj.web_analytics.sessions\` \
|
||||
WHERE date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY) \
|
||||
GROUP BY 1, 2" \
|
||||
--sql "SELECT o.date, o.country, o.revenue, t.views, o.revenue / NULLIF(t.views,0) AS rev_per_view \
|
||||
FROM orders o \
|
||||
JOIN traffic t ON o.date = t.date AND o.country = t.country \
|
||||
ORDER BY 1 DESC"
|
||||
```
|
||||
|
||||
The BQ subquery MUST contain `WHERE` and/or `GROUP BY` to keep the registered result manageable (target: under 500K rows, well under 100 MB). Multiple `--register-bq` flags can compose multiple BQ sources. For complex SQL, use `--stdin` mode (`echo '{"register_bq":{...},"sql":"..."}' | da query --stdin`).
|
||||
|
||||
### BigQuery SQL flavor for `--where`
|
||||
|
||||
Source-typed `bigquery` tables use BigQuery dialect, not DuckDB:
|
||||
|
||||
- Date literal: `DATE '2026-01-01'`
|
||||
- Timestamp literal: `TIMESTAMP '2026-01-01 00:00:00 UTC'`
|
||||
- Now: `CURRENT_DATE()`, `CURRENT_TIMESTAMP()`
|
||||
- Date arithmetic: `DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)`
|
||||
- Regex: `REGEXP_CONTAINS(col, r'pattern')` (raw string!)
|
||||
- Cast: `CAST(x AS INT64)` (NOT `INT`)
|
||||
|
||||
### When the table you want isn't in `da catalog`
|
||||
|
||||
The table may exist in BigQuery but not be registered with Agnes yet. Two options:
|
||||
|
||||
1. **Ad-hoc one-shot** — register a BQ subquery as a view inline, no admin needed
|
||||
if the agnes server SA has BQ access:
|
||||
```
|
||||
da query --register-bq "live=SELECT * FROM \`project.dataset.table\` WHERE date >= '...' LIMIT 1000" \
|
||||
--sql "SELECT * FROM live"
|
||||
```
|
||||
2. **Ask admin to register** the table with `query_mode: "remote"` so it shows up
|
||||
in `da catalog` and supports `da fetch` / `da query --remote`. This is the
|
||||
right path for any table you'll query repeatedly.
|
||||
|
||||
### Deeper guidance
|
||||
|
||||
For the full protocol, including hybrid-query examples, snapshot hygiene, and
|
||||
when NOT to use `da fetch`, run:
|
||||
|
||||
```
|
||||
da skills show agnes-data-querying
|
||||
```
|
||||
|
||||
## Corporate Memory
|
||||
|
||||
Rules injected by `da sync` from the server's corporate knowledge base live in `.claude/rules/km_*.md`. They are automatically loaded by Claude Code on every session start.
|
||||
|
||||
- `km_<id>.md` — mandatory rules (always enforced)
|
||||
- `km_approved.md` — approved guidance (confidence × recency ranked)
|
||||
|
||||
Run `da sync` to refresh. Rules are pruned automatically when items are revoked.
|
||||
|
||||
## Directory Structure
|
||||
- `data/` — read-only data downloaded from server
|
||||
- `data/parquet/` — table data in Parquet format
|
||||
- `data/duckdb/` — local analytics DuckDB database
|
||||
- `data/metadata/` — profiles, schema, metrics cache
|
||||
- `user/` — your workspace (persistent across syncs)
|
||||
- `user/artifacts/` — analysis outputs, reports, charts
|
||||
- `user/sessions/` — Claude Code session logs
|
||||
- `.claude/CLAUDE.local.md` — your personal notes + workspace customizations. **Never overwritten by `da analyst setup --force`.** Uploaded to the server on `da sync --upload-only`. Put any local-only Claude instructions, project-specific reminders, or temporary notes here — NOT in CLAUDE.md (this file is regenerated from a template).
|
||||
|
||||
_Hello {{ user.name or user.email }} — generated {{ today }}._
|
||||
114
docs/agent-workspace-prompt.md
Normal file
114
docs/agent-workspace-prompt.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Agent Workspace Prompt
|
||||
|
||||
The agent workspace prompt is the `CLAUDE.md` file written to each analyst's
|
||||
workspace by `da analyst setup`. It gives Claude Code context about the
|
||||
connected instance: available tables (RBAC-filtered), business metrics, installed
|
||||
plugins, and operational rules for the analyst.
|
||||
|
||||
## When is CLAUDE.md written?
|
||||
|
||||
`da analyst setup` fetches `GET /api/welcome` and writes the rendered markdown
|
||||
to `<workspace>/CLAUDE.md` on every run (including `--force` re-initialisation).
|
||||
|
||||
To skip writing CLAUDE.md:
|
||||
|
||||
```bash
|
||||
da analyst setup --server-url https://agnes.example.com --no-claude-md
|
||||
```
|
||||
|
||||
**Analysts who ran setup while CLAUDE.md generation was temporarily absent** will
|
||||
have their file written on the next `da analyst setup` run. Any existing
|
||||
`CLAUDE.md` is overwritten with the current server template.
|
||||
|
||||
The companion `CLAUDE.local.md` (at `.claude/CLAUDE.local.md`) is **never**
|
||||
overwritten — it is the analyst's personal customisation space.
|
||||
|
||||
## Editing the template
|
||||
|
||||
Admins configure the template via:
|
||||
|
||||
- **Admin UI:** `/admin/workspace-prompt` — Jinja2 markdown editor with a
|
||||
placeholder cheatsheet, live preview (rendered against the calling admin's
|
||||
RBAC context), and save/reset actions.
|
||||
- **REST API:**
|
||||
- `GET /api/admin/workspace-prompt-template` — returns
|
||||
`{content, default, updated_at, updated_by}`. `content` is `null` when no
|
||||
override is set; `default` is always the live rendered default.
|
||||
- `PUT /api/admin/workspace-prompt-template` with body `{"content": "..."}` —
|
||||
validates Jinja2 syntax against two stubs (authenticated user, minimal user)
|
||||
before persisting. Returns `400` on syntax errors or unknown placeholders.
|
||||
- `DELETE /api/admin/workspace-prompt-template` — clears the override; reverts
|
||||
to the rich default template from `config/claude_md_template.txt`.
|
||||
- `POST /api/admin/workspace-prompt-template/preview` with
|
||||
body `{"content": "..."}` — renders arbitrary content against the calling
|
||||
admin's live RBAC context without persisting. Used by the editor's Preview
|
||||
button.
|
||||
|
||||
The override lives in `system.duckdb` (table `claude_md_template`, singleton
|
||||
row id=1). `DELETE` NULLs `content`; audit trail (`updated_at`, `updated_by`)
|
||||
is preserved.
|
||||
|
||||
## Default template
|
||||
|
||||
The default template is `config/claude_md_template.txt` (Jinja2 markdown).
|
||||
When no admin override is set, this file is rendered for every `GET /api/welcome`
|
||||
request. Operators can customise it per-instance via the UI — or ship a modified
|
||||
default by editing the file before deployment.
|
||||
|
||||
## Template language
|
||||
|
||||
[Jinja2](https://jinja.palletsprojects.com/) with `autoescape=False` and
|
||||
`StrictUndefined`. Autoescape is off because the rendered output is markdown, not
|
||||
HTML. `StrictUndefined` means any typo in a placeholder name raises an error at
|
||||
PUT validation time, so the admin is notified immediately.
|
||||
|
||||
## Available placeholders
|
||||
|
||||
| Placeholder | Type | Notes |
|
||||
|---|---|---|
|
||||
| `instance.name` | string | `instance.name` from `instance.yaml` |
|
||||
| `instance.subtitle` | string | `instance.subtitle` from `instance.yaml` |
|
||||
| `server.url` | string | Full server URL at render time |
|
||||
| `server.hostname` | string | Host part only |
|
||||
| `sync_interval` | string | e.g. `"1h"` from `instance.yaml` |
|
||||
| `data_source.type` | string | `keboola`, `bigquery`, or `local` |
|
||||
| `tables` | list[dict] | RBAC-filtered list of `{name, description, query_mode}` |
|
||||
| `metrics.count` | int | Total metric definitions in DB |
|
||||
| `metrics.categories` | list[str] | Sorted unique category names |
|
||||
| `marketplaces` | list[dict] | RBAC-filtered `{slug, name, plugins:[{name}]}` |
|
||||
| `user.id` | string | Analyst user ID |
|
||||
| `user.email` | string | Analyst email |
|
||||
| `user.name` | string | Analyst display name |
|
||||
| `user.is_admin` | bool | Whether the user is in the Admin group |
|
||||
| `user.groups` | list[str] | User's group names |
|
||||
| `now` | datetime (UTC, tz-aware) | Server time at render |
|
||||
| `today` | string (`YYYY-MM-DD`) | Server date |
|
||||
|
||||
## Example: iterating tables
|
||||
|
||||
```jinja2
|
||||
## Available Datasets
|
||||
{% for t in tables -%}
|
||||
- `{{ t.name }}`{% if t.description %} — {{ t.description }}{% endif %}
|
||||
{% else -%}
|
||||
- _No tables registered yet._
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
## Example: conditional marketplace section
|
||||
|
||||
```jinja2
|
||||
{% if marketplaces %}
|
||||
## Plugins
|
||||
{% for mp in marketplaces %}
|
||||
- **{{ mp.name }}**: {{ mp.plugins | map(attribute="name") | join(", ") }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
## Resetting to the built-in default
|
||||
|
||||
Click **Reset to default** in the admin UI, or call
|
||||
`DELETE /api/admin/workspace-prompt-template`. The next analyst who runs
|
||||
`da analyst setup` will receive the rich default template from
|
||||
`config/claude_md_template.txt`.
|
||||
233
src/claude_md.py
Normal file
233
src/claude_md.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
"""Render the analyst-workspace CLAUDE.md prompt.
|
||||
|
||||
The template source is admin-editable at /admin/workspace-prompt. When no
|
||||
override is set, the default content is the Jinja2 markdown template shipped
|
||||
at config/claude_md_template.txt. When an override is saved, it replaces the
|
||||
default for every call to render_claude_md().
|
||||
|
||||
Override content is a Jinja2 template (autoescape=False, StrictUndefined).
|
||||
Available placeholders: instance.{name,subtitle}, server.{url,hostname},
|
||||
sync_interval, data_source.type, tables (list), metrics.{count,categories},
|
||||
marketplaces (RBAC-filtered list), user.{id,email,name,is_admin,groups},
|
||||
now, today.
|
||||
|
||||
See also: surfaced as the "Agent Workspace Prompt" admin editor at
|
||||
/admin/workspace-prompt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import duckdb
|
||||
from jinja2 import Environment, StrictUndefined, TemplateError
|
||||
|
||||
from app.instance_config import (
|
||||
get_data_source_type,
|
||||
get_instance_name,
|
||||
get_instance_subtitle,
|
||||
get_sync_interval,
|
||||
)
|
||||
from src.repositories.claude_md_template import ClaudeMdTemplateRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _load_default_template() -> str:
|
||||
"""Load the shipped CLAUDE.md default template.
|
||||
|
||||
Resolution order (first hit wins):
|
||||
1. importlib.resources lookup in the installed `config` package — works
|
||||
in both editable installs and wheel-installed deployments. This is
|
||||
the canonical path on container deployments where `/app/config/`
|
||||
may be bind-mounted to overlay instance-specific config (instance.yaml)
|
||||
and shadow the image-baked template file.
|
||||
2. Filesystem path relative to this module — for dev runs from a checkout.
|
||||
3. Last-resort embedded fallback so the renderer never fails outright.
|
||||
"""
|
||||
# 1. Package-resource path (preferred — works under wheel installs)
|
||||
try:
|
||||
from importlib import resources
|
||||
|
||||
ref = resources.files("config").joinpath("claude_md_template.txt")
|
||||
if ref.is_file():
|
||||
return ref.read_text(encoding="utf-8")
|
||||
except (ModuleNotFoundError, FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
# 2. Filesystem path relative to this module (dev checkout)
|
||||
fs_path = Path(__file__).resolve().parent.parent / "config" / "claude_md_template.txt"
|
||||
if fs_path.exists():
|
||||
return fs_path.read_text(encoding="utf-8")
|
||||
|
||||
# 3. Embedded fallback (image stripped down, partial Docker COPY, etc.)
|
||||
return (
|
||||
"# {{ instance.name }} — AI Data Analyst\n\n"
|
||||
"This workspace is connected to {{ server.url }}.\n"
|
||||
"Data refreshes every {{ sync_interval }}.\n"
|
||||
)
|
||||
|
||||
|
||||
def _list_tables(conn: duckdb.DuckDBPyConnection, *, user: dict) -> list[dict[str, Any]]:
|
||||
"""Return registered tables filtered by the calling user's RBAC grants.
|
||||
|
||||
For admins, returns all tables. For non-admins, returns only tables the
|
||||
user has explicit ``resource_grants(resource_type='table')`` access to.
|
||||
"""
|
||||
from src.rbac import get_accessible_tables
|
||||
try:
|
||||
allowed_ids = get_accessible_tables(user, conn) # None=admin, list=non-admin
|
||||
if allowed_ids is None:
|
||||
rows = conn.execute(
|
||||
"SELECT name, description, query_mode FROM table_registry ORDER BY name"
|
||||
).fetchall()
|
||||
elif not allowed_ids:
|
||||
return []
|
||||
else:
|
||||
placeholders = ",".join(["?"] * len(allowed_ids))
|
||||
rows = conn.execute(
|
||||
f"SELECT name, description, query_mode FROM table_registry "
|
||||
f"WHERE id IN ({placeholders}) ORDER BY name",
|
||||
allowed_ids,
|
||||
).fetchall()
|
||||
except duckdb.CatalogException:
|
||||
return []
|
||||
return [
|
||||
{"name": r[0], "description": r[1] or "", "query_mode": r[2] or "local"}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _metrics_summary(conn: duckdb.DuckDBPyConnection) -> dict[str, Any]:
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT category, COUNT(*) FROM metric_definitions GROUP BY category"
|
||||
).fetchall()
|
||||
except duckdb.CatalogException:
|
||||
return {"count": 0, "categories": []}
|
||||
return {
|
||||
"count": sum(r[1] for r in rows),
|
||||
"categories": sorted({r[0] for r in rows if r[0]}),
|
||||
}
|
||||
|
||||
|
||||
def _marketplaces_for_user(
|
||||
conn: duckdb.DuckDBPyConnection, user: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return marketplaces with the plugins the user is allowed to see.
|
||||
|
||||
Delegates RBAC filtering entirely to resolve_allowed_plugins, which
|
||||
returns List[dict] with marketplace_slug, original_name, etc.
|
||||
Results are grouped by marketplace slug; display names are fetched
|
||||
from marketplace_registry in a single query.
|
||||
"""
|
||||
try:
|
||||
from src.marketplace_filter import resolve_allowed_plugins
|
||||
allowed = resolve_allowed_plugins(conn, user)
|
||||
except Exception:
|
||||
logger.exception("_marketplaces_for_user: marketplace plugin resolution failed")
|
||||
return []
|
||||
if not allowed:
|
||||
return []
|
||||
|
||||
# Build slug → display name lookup from registry
|
||||
slugs = list({p["marketplace_slug"] for p in allowed})
|
||||
placeholders = ",".join(["?"] * len(slugs))
|
||||
try:
|
||||
name_rows = conn.execute(
|
||||
f"SELECT id, name FROM marketplace_registry WHERE id IN ({placeholders})",
|
||||
slugs,
|
||||
).fetchall()
|
||||
except duckdb.CatalogException:
|
||||
name_rows = []
|
||||
slug_to_name: dict[str, str] = {r[0]: r[1] for r in name_rows}
|
||||
|
||||
grouped: dict[str, dict[str, Any]] = {}
|
||||
for plugin in allowed:
|
||||
slug = plugin["marketplace_slug"]
|
||||
bucket = grouped.setdefault(
|
||||
slug,
|
||||
{
|
||||
"slug": slug,
|
||||
"name": slug_to_name.get(slug, slug),
|
||||
"plugins": [],
|
||||
},
|
||||
)
|
||||
bucket["plugins"].append({"name": plugin["original_name"]})
|
||||
|
||||
return list(grouped.values())
|
||||
|
||||
|
||||
def build_claude_md_context(
|
||||
conn: duckdb.DuckDBPyConnection,
|
||||
*,
|
||||
user: dict[str, Any],
|
||||
server_url: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Compose the Jinja2 render context for the CLAUDE.md template. Pure, no side effects."""
|
||||
now = datetime.now(timezone.utc)
|
||||
parsed = urlparse(server_url)
|
||||
return {
|
||||
"instance": {
|
||||
"name": get_instance_name(),
|
||||
"subtitle": get_instance_subtitle(),
|
||||
},
|
||||
"server": {
|
||||
"url": server_url,
|
||||
"hostname": parsed.hostname or "",
|
||||
},
|
||||
"sync_interval": get_sync_interval(),
|
||||
"data_source": {"type": get_data_source_type()},
|
||||
"tables": _list_tables(conn, user=user),
|
||||
"metrics": _metrics_summary(conn),
|
||||
"marketplaces": _marketplaces_for_user(conn, user),
|
||||
"user": {
|
||||
"id": user.get("id", ""),
|
||||
"email": user.get("email", ""),
|
||||
"name": user.get("name") or "",
|
||||
"is_admin": bool(user.get("is_admin")),
|
||||
"groups": user.get("groups") or [],
|
||||
},
|
||||
"now": now,
|
||||
"today": now.date().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def compute_default_claude_md(
|
||||
conn: duckdb.DuckDBPyConnection,
|
||||
*,
|
||||
user: dict[str, Any],
|
||||
server_url: str,
|
||||
) -> str:
|
||||
"""Return the rendered default CLAUDE.md from config/claude_md_template.txt.
|
||||
|
||||
Renders the shipped Jinja2 template with the given user's RBAC context.
|
||||
On TemplateError, raises — callers that want graceful fallback should catch.
|
||||
"""
|
||||
source = _load_default_template()
|
||||
env = Environment(undefined=StrictUndefined, autoescape=False)
|
||||
template = env.from_string(source)
|
||||
return template.render(**build_claude_md_context(conn, user=user, server_url=server_url))
|
||||
|
||||
|
||||
def render_claude_md(
|
||||
conn: duckdb.DuckDBPyConnection,
|
||||
*,
|
||||
user: dict[str, Any],
|
||||
server_url: str,
|
||||
) -> str:
|
||||
"""Resolve the active template (override or default) and render it for the given user.
|
||||
|
||||
When an admin override is set, renders it via Jinja2 (StrictUndefined, autoescape=False).
|
||||
When no override is set, renders the shipped default template.
|
||||
|
||||
On TemplateError, raises — the API layer catches this and returns 400/500.
|
||||
"""
|
||||
row = ClaudeMdTemplateRepository(conn).get()
|
||||
source = row["content"] if row.get("content") else _load_default_template()
|
||||
env = Environment(undefined=StrictUndefined, autoescape=False)
|
||||
template = env.from_string(source)
|
||||
return template.render(**build_claude_md_context(conn, user=user, server_url=server_url))
|
||||
32
src/db.py
32
src/db.py
|
|
@ -39,7 +39,7 @@ def _maybe_instrument(con, db_tag: str):
|
|||
|
||||
_SAFE_IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
|
||||
|
||||
SCHEMA_VERSION = 22
|
||||
SCHEMA_VERSION = 23
|
||||
|
||||
_SYSTEM_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
|
|
@ -427,6 +427,18 @@ CREATE TABLE IF NOT EXISTS setup_banner (
|
|||
updated_by VARCHAR,
|
||||
CONSTRAINT singleton CHECK (id = 1)
|
||||
);
|
||||
|
||||
-- v23: customizable analyst-workspace CLAUDE.md template.
|
||||
-- Singleton row (id=1). NULL content means "use the default template
|
||||
-- shipped at config/claude_md_template.txt" (Jinja2 markdown). Admin override
|
||||
-- stores the raw Jinja2 source string.
|
||||
CREATE TABLE IF NOT EXISTS claude_md_template (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
content TEXT,
|
||||
updated_at TIMESTAMP,
|
||||
updated_by VARCHAR,
|
||||
CONSTRAINT singleton CHECK (id = 1)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
|
|
@ -1658,6 +1670,17 @@ _V21_TO_V22_MIGRATIONS = [
|
|||
"INSERT INTO setup_banner (id, content) VALUES (1, NULL) ON CONFLICT (id) DO NOTHING",
|
||||
]
|
||||
|
||||
_V22_TO_V23_MIGRATIONS = [
|
||||
"""CREATE TABLE IF NOT EXISTS claude_md_template (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
content TEXT,
|
||||
updated_at TIMESTAMP,
|
||||
updated_by VARCHAR,
|
||||
CONSTRAINT singleton CHECK (id = 1)
|
||||
)""",
|
||||
"INSERT INTO claude_md_template (id, content) VALUES (1, NULL) ON CONFLICT (id) DO NOTHING",
|
||||
]
|
||||
|
||||
|
||||
def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
|
||||
"""Create tables if they don't exist. Apply migrations if schema version changed.
|
||||
|
|
@ -1724,6 +1747,10 @@ def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
|
|||
"INSERT INTO setup_banner (id, content) VALUES (1, NULL) "
|
||||
"ON CONFLICT (id) DO NOTHING"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO claude_md_template (id, content) VALUES (1, NULL) "
|
||||
"ON CONFLICT (id) DO NOTHING"
|
||||
)
|
||||
# Fresh-install seed is handled by the unconditional
|
||||
# _seed_core_roles call at the bottom of _ensure_schema —
|
||||
# left as a no-op branch here so the migration ladder still
|
||||
|
|
@ -1807,6 +1834,9 @@ def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
|
|||
if current < 22:
|
||||
for sql in _V21_TO_V22_MIGRATIONS:
|
||||
conn.execute(sql)
|
||||
if current < 23:
|
||||
for sql in _V22_TO_V23_MIGRATIONS:
|
||||
conn.execute(sql)
|
||||
conn.execute(
|
||||
"UPDATE schema_version SET version = ?, applied_at = current_timestamp",
|
||||
[SCHEMA_VERSION],
|
||||
|
|
|
|||
53
src/repositories/claude_md_template.py
Normal file
53
src/repositories/claude_md_template.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""Repository for the per-instance CLAUDE.md template override (singleton row)."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import duckdb
|
||||
|
||||
|
||||
class ClaudeMdTemplateRepository:
|
||||
def __init__(self, conn: duckdb.DuckDBPyConnection):
|
||||
self.conn = conn
|
||||
|
||||
def get(self) -> dict[str, Any]:
|
||||
"""Return the singleton row. Always exists post-migration; content
|
||||
is None when no override is set (= use shipped default template)."""
|
||||
row = self.conn.execute(
|
||||
"SELECT id, content, updated_at, updated_by FROM claude_md_template WHERE id = 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
# Defensive: re-seed if a previous admin manually deleted it.
|
||||
self.conn.execute(
|
||||
"INSERT INTO claude_md_template (id, content) VALUES (1, NULL) "
|
||||
"ON CONFLICT (id) DO NOTHING"
|
||||
)
|
||||
return {"id": 1, "content": None, "updated_at": None, "updated_by": None}
|
||||
return {
|
||||
"id": row[0],
|
||||
"content": row[1],
|
||||
"updated_at": row[2],
|
||||
"updated_by": row[3],
|
||||
}
|
||||
|
||||
def set(self, content: str, *, updated_by: str) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
self.conn.execute(
|
||||
"""INSERT INTO claude_md_template (id, content, updated_at, updated_by)
|
||||
VALUES (1, ?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
content = excluded.content,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by""",
|
||||
[content, now, updated_by],
|
||||
)
|
||||
|
||||
def reset(self, *, updated_by: str) -> None:
|
||||
"""Clear override; renderer falls back to shipped default template."""
|
||||
now = datetime.now(timezone.utc)
|
||||
self.conn.execute(
|
||||
"""UPDATE claude_md_template
|
||||
SET content = NULL, updated_at = ?, updated_by = ?
|
||||
WHERE id = 1""",
|
||||
[now, updated_by],
|
||||
)
|
||||
|
|
@ -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()
|
||||
274
tests/test_claude_md_renderer.py
Normal file
274
tests/test_claude_md_renderer.py
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
"""Unit tests for the analyst-workspace CLAUDE.md renderer (src/claude_md.py)."""
|
||||
|
||||
import duckdb
|
||||
import pytest
|
||||
from jinja2 import TemplateError
|
||||
|
||||
from src.db import _ensure_schema
|
||||
from src.repositories.claude_md_template import ClaudeMdTemplateRepository
|
||||
from src.claude_md import (
|
||||
build_claude_md_context,
|
||||
compute_default_claude_md,
|
||||
render_claude_md,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
db_path = tmp_path / "system.duckdb"
|
||||
c = duckdb.connect(str(db_path))
|
||||
_ensure_schema(c)
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
|
||||
def _user(email="alice@example.com", is_admin=False):
|
||||
return {
|
||||
"id": "u1",
|
||||
"email": email,
|
||||
"name": "Alice",
|
||||
"is_admin": is_admin,
|
||||
"groups": ["Everyone"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default (no override) — renders a non-empty markdown string
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_compute_default_returns_non_empty(conn):
|
||||
out = compute_default_claude_md(conn, user=_user(), server_url="https://example.com")
|
||||
assert out.strip() != ""
|
||||
|
||||
|
||||
def test_default_contains_server_url(conn):
|
||||
out = compute_default_claude_md(conn, user=_user(), server_url="https://myagnes.example.com")
|
||||
assert "https://myagnes.example.com" in out
|
||||
|
||||
|
||||
def test_default_contains_user_reference(conn):
|
||||
# The footer uses `user.name or user.email` — a user with no name falls back to email.
|
||||
user_no_name = {"id": "u1", "email": "bob@example.com", "name": "", "is_admin": False, "groups": []}
|
||||
out = compute_default_claude_md(conn, user=user_no_name, server_url="https://example.com")
|
||||
assert "bob@example.com" in out
|
||||
|
||||
|
||||
def test_render_uses_default_when_no_override(conn):
|
||||
out = render_claude_md(conn, user=_user(), server_url="https://example.com")
|
||||
assert out.strip() != ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Override renders correctly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_render_uses_override_when_set(conn):
|
||||
ClaudeMdTemplateRepository(conn).set(
|
||||
"# {{ instance.name }} Workspace\n\nHello {{ user.email }}.",
|
||||
updated_by="admin@example.com",
|
||||
)
|
||||
out = render_claude_md(conn, user=_user("charlie@example.com"), server_url="https://example.com")
|
||||
assert "charlie@example.com" in out
|
||||
|
||||
|
||||
def test_render_override_tables_list(conn):
|
||||
# Seed a table registry entry and ensure the test user is an admin so
|
||||
# RBAC filtering does not hide the table.
|
||||
conn.execute(
|
||||
"INSERT INTO table_registry (id, name, description, query_mode, source_type) "
|
||||
"VALUES ('t1', 'orders', 'All orders', 'local', 'keboola')"
|
||||
)
|
||||
from src.repositories.users import UserRepository
|
||||
from src.repositories.user_group_members import UserGroupMembersRepository
|
||||
UserRepository(conn).create(id="u1", email="alice@example.com", name="Alice")
|
||||
admin_gid = conn.execute("SELECT id FROM user_groups WHERE name='Admin'").fetchone()[0]
|
||||
UserGroupMembersRepository(conn).add_member("u1", admin_gid, source="admin")
|
||||
ClaudeMdTemplateRepository(conn).set(
|
||||
"{% for t in tables %}- {{ t.name }}: {{ t.description }}{% endfor %}",
|
||||
updated_by="admin@example.com",
|
||||
)
|
||||
out = render_claude_md(conn, user=_user(), server_url="https://example.com")
|
||||
assert "orders" in out
|
||||
assert "All orders" in out
|
||||
|
||||
|
||||
def test_render_override_metrics_summary(conn):
|
||||
# Seed a metric definition — must include NOT NULL columns: display_name, sql
|
||||
conn.execute(
|
||||
"INSERT INTO metric_definitions (id, name, display_name, category, sql) "
|
||||
"VALUES ('m1', 'mrr', 'MRR', 'revenue', 'SELECT SUM(amount)')"
|
||||
)
|
||||
ClaudeMdTemplateRepository(conn).set(
|
||||
"Metrics: {{ metrics.count }}, cats: {{ metrics.categories | join(', ') }}",
|
||||
updated_by="admin@example.com",
|
||||
)
|
||||
out = render_claude_md(conn, user=_user(), server_url="https://example.com")
|
||||
assert "1" in out # 1 metric
|
||||
assert "revenue" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RBAC-filtered marketplaces — two users with different grants render differently
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_marketplaces_empty_for_user_with_no_grants(conn):
|
||||
# No grants seeded — _marketplaces_for_user returns []
|
||||
ClaudeMdTemplateRepository(conn).set(
|
||||
"{% if marketplaces %}HAS_PLUGINS{% else %}NO_PLUGINS{% endif %}",
|
||||
updated_by="admin@example.com",
|
||||
)
|
||||
out = render_claude_md(conn, user=_user(), server_url="https://example.com")
|
||||
assert "NO_PLUGINS" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Anonymous / minimal user context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_render_with_minimal_user_context(conn):
|
||||
"""Templates referencing user fields must work with minimal user dict."""
|
||||
ClaudeMdTemplateRepository(conn).set(
|
||||
"User: {{ user.email }}, admin: {{ user.is_admin }}",
|
||||
updated_by="admin@example.com",
|
||||
)
|
||||
out = render_claude_md(conn, user=_user(), server_url="https://example.com")
|
||||
assert "alice@example.com" in out
|
||||
assert "False" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build context shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_context_exposes_all_documented_keys(conn):
|
||||
ctx = build_claude_md_context(conn, user=_user(), server_url="https://example.com")
|
||||
for key in ("instance", "server", "sync_interval", "data_source", "tables", "metrics", "marketplaces", "user", "now", "today"):
|
||||
assert key in ctx, f"missing context key: {key}"
|
||||
|
||||
|
||||
def test_context_tables_is_list(conn):
|
||||
ctx = build_claude_md_context(conn, user=_user(), server_url="https://example.com")
|
||||
assert isinstance(ctx["tables"], list)
|
||||
|
||||
|
||||
def test_context_metrics_shape(conn):
|
||||
ctx = build_claude_md_context(conn, user=_user(), server_url="https://example.com")
|
||||
assert "count" in ctx["metrics"]
|
||||
assert "categories" in ctx["metrics"]
|
||||
|
||||
|
||||
def test_context_marketplaces_is_list(conn):
|
||||
ctx = build_claude_md_context(conn, user=_user(), server_url="https://example.com")
|
||||
assert isinstance(ctx["marketplaces"], list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Render failure raises (caller handles)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_render_raises_on_template_error(conn):
|
||||
ClaudeMdTemplateRepository(conn).set(
|
||||
"{{ does_not_exist }}", updated_by="admin@example.com"
|
||||
)
|
||||
with pytest.raises(TemplateError):
|
||||
render_claude_md(conn, user=_user(), server_url="https://example.com")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RBAC-filtered tables — two users with different grants see different tables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_user(conn, *, user_id: str, email: str) -> None:
|
||||
from src.repositories.users import UserRepository
|
||||
UserRepository(conn).create(id=user_id, email=email, name=email.split("@")[0])
|
||||
|
||||
|
||||
def _make_group(conn, *, name: str) -> str:
|
||||
from src.repositories.user_groups import UserGroupsRepository
|
||||
return UserGroupsRepository(conn).create(name=name)["id"]
|
||||
|
||||
|
||||
def _add_member(conn, *, user_id: str, group_id: str) -> None:
|
||||
from src.repositories.user_group_members import UserGroupMembersRepository
|
||||
UserGroupMembersRepository(conn).add_member(user_id, group_id, source="admin")
|
||||
|
||||
|
||||
def _grant_table(conn, *, group_id: str, table_id: str) -> None:
|
||||
from src.repositories.resource_grants import ResourceGrantsRepository
|
||||
ResourceGrantsRepository(conn).create(
|
||||
group_id=group_id, resource_type="table", resource_id=table_id
|
||||
)
|
||||
|
||||
|
||||
def test_render_tables_filtered_by_rbac(conn):
|
||||
"""Non-admin users see only tables granted to their groups."""
|
||||
# Seed two tables
|
||||
conn.execute(
|
||||
"INSERT INTO table_registry (id, name, description, query_mode, source_type) "
|
||||
"VALUES ('t-a', 'orders', 'Order data', 'local', 'keboola')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO table_registry (id, name, description, query_mode, source_type) "
|
||||
"VALUES ('t-b', 'revenue', 'Revenue data', 'local', 'keboola')"
|
||||
)
|
||||
|
||||
# Two users, two groups
|
||||
_make_user(conn, user_id="ua", email="alice@example.com")
|
||||
_make_user(conn, user_id="ub", email="bob@example.com")
|
||||
gid_a = _make_group(conn, name="group-a")
|
||||
gid_b = _make_group(conn, name="group-b")
|
||||
_add_member(conn, user_id="ua", group_id=gid_a)
|
||||
_add_member(conn, user_id="ub", group_id=gid_b)
|
||||
|
||||
# Grant: group-a → t-a, group-b → t-b
|
||||
_grant_table(conn, group_id=gid_a, table_id="t-a")
|
||||
_grant_table(conn, group_id=gid_b, table_id="t-b")
|
||||
|
||||
user_a = {"id": "ua", "email": "alice@example.com", "name": "Alice", "is_admin": False, "groups": []}
|
||||
user_b = {"id": "ub", "email": "bob@example.com", "name": "Bob", "is_admin": False, "groups": []}
|
||||
|
||||
ctx_a = build_claude_md_context(conn, user=user_a, server_url="https://example.com")
|
||||
table_names_a = {t["name"] for t in ctx_a["tables"]}
|
||||
assert "orders" in table_names_a
|
||||
assert "revenue" not in table_names_a
|
||||
|
||||
ctx_b = build_claude_md_context(conn, user=user_b, server_url="https://example.com")
|
||||
table_names_b = {t["name"] for t in ctx_b["tables"]}
|
||||
assert "revenue" in table_names_b
|
||||
assert "orders" not in table_names_b
|
||||
|
||||
|
||||
def test_render_tables_admin_sees_all(conn):
|
||||
"""Admin users see all tables regardless of grants."""
|
||||
conn.execute(
|
||||
"INSERT INTO table_registry (id, name, description, query_mode, source_type) "
|
||||
"VALUES ('t-x', 'alpha', 'Alpha table', 'local', 'keboola')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO table_registry (id, name, description, query_mode, source_type) "
|
||||
"VALUES ('t-y', 'beta', 'Beta table', 'local', 'keboola')"
|
||||
)
|
||||
|
||||
# Admin user: member of the Admin system group
|
||||
_make_user(conn, user_id="u-admin", email="admin@example.com")
|
||||
admin_gid = conn.execute("SELECT id FROM user_groups WHERE name='Admin'").fetchone()[0]
|
||||
_add_member(conn, user_id="u-admin", group_id=admin_gid)
|
||||
|
||||
user_admin = {"id": "u-admin", "email": "admin@example.com", "name": "Admin", "is_admin": True, "groups": []}
|
||||
ctx = build_claude_md_context(conn, user=user_admin, server_url="https://example.com")
|
||||
table_names = {t["name"] for t in ctx["tables"]}
|
||||
assert "alpha" in table_names
|
||||
assert "beta" in table_names
|
||||
|
||||
|
||||
def test_render_tables_empty_for_user_with_no_grants(conn):
|
||||
"""Non-admin with no grants sees no tables."""
|
||||
conn.execute(
|
||||
"INSERT INTO table_registry (id, name, description, query_mode, source_type) "
|
||||
"VALUES ('t-z', 'secret', 'Secret table', 'local', 'keboola')"
|
||||
)
|
||||
_make_user(conn, user_id="u-none", email="none@example.com")
|
||||
user_none = {"id": "u-none", "email": "none@example.com", "name": "None", "is_admin": False, "groups": []}
|
||||
ctx = build_claude_md_context(conn, user=user_none, server_url="https://example.com")
|
||||
assert ctx["tables"] == []
|
||||
40
tests/test_claude_md_template_repo.py
Normal file
40
tests/test_claude_md_template_repo.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Unit tests for ClaudeMdTemplateRepository."""
|
||||
|
||||
import duckdb
|
||||
import pytest
|
||||
|
||||
from src.db import _ensure_schema
|
||||
from src.repositories.claude_md_template import ClaudeMdTemplateRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn(tmp_path):
|
||||
db_path = tmp_path / "system.duckdb"
|
||||
c = duckdb.connect(str(db_path))
|
||||
_ensure_schema(c)
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
|
||||
def test_get_returns_none_on_fresh_install(conn):
|
||||
repo = ClaudeMdTemplateRepository(conn)
|
||||
row = repo.get()
|
||||
assert row is not None
|
||||
assert row["content"] is None # default sentinel
|
||||
|
||||
|
||||
def test_set_stores_content(conn):
|
||||
repo = ClaudeMdTemplateRepository(conn)
|
||||
repo.set("# {{ instance.name }}", updated_by="admin@example.com")
|
||||
row = repo.get()
|
||||
assert row["content"] == "# {{ instance.name }}"
|
||||
assert row["updated_by"] == "admin@example.com"
|
||||
assert row["updated_at"] is not None
|
||||
|
||||
|
||||
def test_reset_clears_content(conn):
|
||||
repo = ClaudeMdTemplateRepository(conn)
|
||||
repo.set("custom template", updated_by="admin@example.com")
|
||||
repo.reset(updated_by="admin@example.com")
|
||||
row = repo.get()
|
||||
assert row["content"] is None
|
||||
|
|
@ -13,8 +13,8 @@ import duckdb
|
|||
from src.db import SCHEMA_VERSION, _ensure_schema, get_schema_version
|
||||
|
||||
|
||||
def test_schema_version_is_22():
|
||||
assert SCHEMA_VERSION == 22
|
||||
def test_schema_version_is_23():
|
||||
assert SCHEMA_VERSION == 23
|
||||
|
||||
|
||||
def test_v20_adds_source_query(tmp_path):
|
||||
|
|
@ -29,7 +29,29 @@ def test_v20_adds_source_query(tmp_path):
|
|||
).fetchall()
|
||||
}
|
||||
assert "source_query" in cols, f"source_query missing from {cols}"
|
||||
assert get_schema_version(conn) == 22
|
||||
assert get_schema_version(conn) == 23
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_v23_adds_claude_md_template(tmp_path):
|
||||
"""v23 must create the claude_md_template singleton table."""
|
||||
db_path = tmp_path / "system.duckdb"
|
||||
conn = duckdb.connect(str(db_path))
|
||||
_ensure_schema(conn)
|
||||
|
||||
tables = {
|
||||
r[0] for r in conn.execute(
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_schema = 'main'"
|
||||
).fetchall()
|
||||
}
|
||||
assert "claude_md_template" in tables, f"claude_md_template missing from {tables}"
|
||||
|
||||
# Singleton row seeded
|
||||
row = conn.execute("SELECT id, content FROM claude_md_template WHERE id = 1").fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == 1
|
||||
assert row[1] is None # default = no override
|
||||
conn.close()
|
||||
|
||||
|
||||
|
|
@ -61,7 +83,7 @@ def test_v19_db_migrates_to_v20(tmp_path):
|
|||
|
||||
_ensure_schema(conn)
|
||||
|
||||
assert get_schema_version(conn) == 22
|
||||
assert get_schema_version(conn) == 23
|
||||
cols = {
|
||||
r[0] for r in conn.execute(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
|
|
|
|||
|
|
@ -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