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 @@
+
+