diff --git a/CHANGELOG.md b/CHANGELOG.md index fee421a..fffc8e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C ### Added -- **Agent Setup Prompt** — customizable HTML banner shown above the bash bootstrap commands on `/setup`. Empty by default. Configure at `/admin/agent-prompt` (Jinja2 HTML editor). REST API: `GET /api/admin/welcome-template` returns `{content, updated_at, updated_by}` (`content` is `null` when no override is set); `PUT` to set an override; `DELETE` to clear; `POST /api/admin/welcome-template/preview` for live HTML preview without persisting. Available placeholders: `instance.{name,subtitle}`, `server.{url,hostname}`, `user` (may be `null` for anonymous visitors), `now`, `today`. HTML sanitization applied post-render (script/iframe/event-handler strip). See `docs/agent-setup-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 v22: `setup_banner` table reserved (no consumers; retained for forward compatibility with already-migrated instances). diff --git a/app/api/welcome.py b/app/api/welcome.py index 9d279cf..4bacd3d 100644 --- a/app/api/welcome.py +++ b/app/api/welcome.py @@ -1,8 +1,8 @@ -"""REST endpoints for the agent-setup-prompt banner. +"""REST endpoints for the agent-setup-prompt. -- GET /api/admin/welcome-template : raw template override (admin) +- GET /api/admin/welcome-template : raw template override + live default (admin) - PUT /api/admin/welcome-template : set override (admin) -- DELETE /api/admin/welcome-template : reset to default / no banner (admin) +- DELETE /api/admin/welcome-template : reset to default (admin) - POST /api/admin/welcome-template/preview : live preview without persisting (admin) """ @@ -18,7 +18,7 @@ from pydantic import BaseModel, Field from app.auth.access import require_admin from app.auth.dependencies import _get_db from src.repositories.welcome_template import WelcomeTemplateRepository -from src.welcome_template import build_context, render_agent_prompt_banner +from src.welcome_template import build_context, compute_default_agent_prompt, render_agent_prompt_banner logger = logging.getLogger(__name__) @@ -50,6 +50,7 @@ class BannerResponse(BaseModel): class TemplateGetResponse(BaseModel): content: Optional[str] + default: str # live default from setup_instructions.resolve_lines() updated_at: Optional[str] = None updated_by: Optional[str] = None @@ -64,12 +65,16 @@ class TemplatePreviewRequest(BaseModel): @router.get("/api/admin/welcome-template", response_model=TemplateGetResponse) async def admin_get_template( + request: Request, user: dict = Depends(require_admin), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): row = WelcomeTemplateRepository(conn).get() + server_url = str(request.base_url).rstrip("/") + live_default = compute_default_agent_prompt(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"], ) diff --git a/app/web/router.py b/app/web/router.py index e962513..d9fb89c 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -726,18 +726,52 @@ async def setup_page( user: Optional[dict] = Depends(get_optional_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): - """Setup instructions for the local agent (CLI + Claude Code).""" - from src.welcome_template import render_agent_prompt_banner + """Setup instructions for the local agent (CLI + Claude Code). + + When an admin override is saved, the override replaces the auto-generated + setup_instructions output everywhere (both the /setup page display and the + dashboard clipboard CTA). When no override is set, the live default from + setup_instructions.resolve_lines() is used. + """ + from src.repositories.welcome_template import WelcomeTemplateRepository + from src.welcome_template import compute_default_agent_prompt + from jinja2 import Environment, StrictUndefined, TemplateError base_url = str(request.base_url).rstrip("/") - banner_html = render_agent_prompt_banner(conn, user=user, server_url=base_url) + + # Determine the script text: override (Jinja2-rendered) or live default. + row = WelcomeTemplateRepository(conn).get() + override_content = row.get("content") + if override_content: + # Admin override — render Jinja2 placeholders server-side. + # {server_url} and {token} survive because Jinja2 only processes + # double-brace {{ }} syntax; single-brace {x} pass through unchanged. + try: + from src.welcome_template import build_context as _build_banner_ctx + env = Environment(undefined=StrictUndefined, autoescape=False) + template = env.from_string(override_content) + ctx_vars = _build_banner_ctx(user=user, server_url=base_url) + setup_script_text = template.render(**ctx_vars) + except (TemplateError, Exception) as exc: + logger.warning("setup_page: override render failed (%s); falling back to default", exc) + setup_script_text = compute_default_agent_prompt(conn, user=user, server_url=base_url) + else: + setup_script_text = compute_default_agent_prompt(conn, user=user, server_url=base_url) + + # Split for the legacy setup_instructions_lines list variable that the + # Jinja2 partial (_claude_setup_instructions.jinja) uses. + setup_instructions_lines = setup_script_text.split("\n") + ctx = _build_context( request, user=user, conn=conn, server_url=base_url, agnes_version=os.environ.get("AGNES_VERSION", "dev"), - banner_html=banner_html, + banner_html="", # no separate banner — the script IS the content + # Override both variables so the partial and the JS array stay in sync. + setup_instructions_lines=setup_instructions_lines, + setup_script_text=setup_script_text, ) return templates.TemplateResponse(request, "install.html", ctx) @@ -905,12 +939,16 @@ async def admin_agent_prompt_page( conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): from src.repositories.welcome_template import WelcomeTemplateRepository + from src.welcome_template import compute_default_agent_prompt row = WelcomeTemplateRepository(conn).get() + base_url = str(request.base_url).rstrip("/") + default_template = compute_default_agent_prompt(conn, user=user, server_url=base_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, diff --git a/app/web/templates/admin_welcome.html b/app/web/templates/admin_welcome.html index 8c508df..0da4579 100644 --- a/app/web/templates/admin_welcome.html +++ b/app/web/templates/admin_welcome.html @@ -8,12 +8,16 @@ + +