diff --git a/app/api/welcome.py b/app/api/welcome.py index 4bacd3d..ee30583 100644 --- a/app/api/welcome.py +++ b/app/api/welcome.py @@ -119,7 +119,9 @@ async def admin_preview_template( """Render arbitrary template content against the live context for the calling admin, without persisting. Used by the /admin/agent-prompt editor's Preview button so admins can see their edits before saving.""" - env = Environment(undefined=StrictUndefined, autoescape=True) + # autoescape=False to match /setup rendering — the outer Jinja2 template + # applies escaping where needed. + env = Environment(undefined=StrictUndefined, autoescape=False) try: template = env.from_string(payload.content) ctx = build_context( diff --git a/app/web/router.py b/app/web/router.py index d9fb89c..2d0d56b 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -734,7 +734,7 @@ async def setup_page( setup_instructions.resolve_lines() is used. """ from src.repositories.welcome_template import WelcomeTemplateRepository - from src.welcome_template import compute_default_agent_prompt + from src.welcome_template import compute_default_agent_prompt, _sanitize_banner_html from jinja2 import Environment, StrictUndefined, TemplateError base_url = str(request.base_url).rstrip("/") @@ -751,7 +751,7 @@ async def setup_page( 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) + setup_script_text = _sanitize_banner_html(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) diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index a1b6e91..4ec7630 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -2334,6 +2334,12 @@ + {% if account_details and account_details.last_sync_display %} +
+ {% endif %} + {% else %} @@ -2427,12 +2433,6 @@ - {% if account_details and account_details.last_sync_display %} - - {% endif %} - diff --git a/cli/commands/analyst.py b/cli/commands/analyst.py index 57f8e3d..c1a6374 100644 --- a/cli/commands/analyst.py +++ b/cli/commands/analyst.py @@ -17,16 +17,16 @@ analyst_app = typer.Typer(help="Analyst workspace bootstrap and status") # Helper: detect existing workspace # --------------------------------------------------------------------------- -_CLAUDE_MD_MARKER = "AI Data Analyst" - - def _detect_existing_project(workspace: Path) -> bool: - """Return True if CLAUDE.md with the analyst identifier already exists.""" - claude_md = workspace / "CLAUDE.md" - if claude_md.exists(): - content = claude_md.read_text(encoding="utf-8") - return _CLAUDE_MD_MARKER in content - return False + """Return True if .claude/settings.json with the agnes da-sync hooks exists.""" + settings = workspace / ".claude" / "settings.json" + if not settings.exists(): + return False + try: + content = settings.read_text(encoding="utf-8") + except OSError: + return False + return "da sync" in content # --------------------------------------------------------------------------- diff --git a/src/welcome_template.py b/src/welcome_template.py index 572b2c1..72f4ab5 100644 --- a/src/welcome_template.py +++ b/src/welcome_template.py @@ -7,7 +7,7 @@ script (TLS trust, CLI install, login, marketplace, skills). When an override is saved it replaces the default everywhere: both the /setup page display and the dashboard clipboard CTA. -Override content is a Jinja2 template (autoescape=True, StrictUndefined). +Override content is a Jinja2 template (autoescape=False, StrictUndefined). Available placeholders: instance.{name,subtitle}, server.{url,hostname}, user (may be None for anonymous visitors), now, today. @@ -215,9 +215,11 @@ def render_agent_prompt_banner( content = row.get("content") if content: - # Admin-authored override — render as Jinja2 HTML, sanitize. + # Admin-authored override — render as Jinja2, sanitize. + # autoescape=False to match /setup rendering — the outer Jinja2 template + # applies escaping where needed. try: - env = Environment(undefined=StrictUndefined, autoescape=True) + env = Environment(undefined=StrictUndefined, autoescape=False) template = env.from_string(content) ctx = build_context(user=user, server_url=server_url) rendered = template.render(**ctx) diff --git a/tests/test_analyst_bootstrap.py b/tests/test_analyst_bootstrap.py index 3c4b8f5..1f88e16 100644 --- a/tests/test_analyst_bootstrap.py +++ b/tests/test_analyst_bootstrap.py @@ -34,45 +34,64 @@ def tmp_workspace(tmp_path, monkeypatch): # --------------------------------------------------------------------------- class TestDetectExistingProject: - def test_no_claude_md_returns_false(self, tmp_workspace): + def test_no_settings_json_returns_false(self, tmp_workspace): from cli.commands.analyst import _detect_existing_project assert _detect_existing_project(tmp_workspace) is False - def test_claude_md_with_marker_returns_true(self, tmp_workspace): + def test_settings_json_with_da_sync_returns_true(self, tmp_workspace): from cli.commands.analyst import _detect_existing_project + import json as _json - (tmp_workspace / "CLAUDE.md").write_text( - "# Acme — AI Data Analyst\n\nThis workspace is connected to http://localhost:8000.\n", - encoding="utf-8", - ) + claude_dir = tmp_workspace / ".claude" + claude_dir.mkdir(parents=True, exist_ok=True) + settings = { + "hooks": { + "SessionStart": [{"hooks": [{"type": "command", "command": "da sync --quiet 2>/dev/null || true"}]}], + } + } + (claude_dir / "settings.json").write_text(_json.dumps(settings), encoding="utf-8") assert _detect_existing_project(tmp_workspace) is True - def test_claude_md_without_marker_returns_false(self, tmp_workspace): + def test_settings_json_without_da_sync_returns_false(self, tmp_workspace): from cli.commands.analyst import _detect_existing_project + import json as _json - (tmp_workspace / "CLAUDE.md").write_text( - "# Some Other Project\n\nNot an analyst workspace.\n", - encoding="utf-8", + claude_dir = tmp_workspace / ".claude" + claude_dir.mkdir(parents=True, exist_ok=True) + (claude_dir / "settings.json").write_text( + _json.dumps({"model": "sonnet"}), encoding="utf-8" ) assert _detect_existing_project(tmp_workspace) is False def test_setup_blocked_when_existing_without_force(self, tmp_workspace): """Setup must exit(1) when workspace exists and --force not supplied.""" - (tmp_workspace / "CLAUDE.md").write_text( - "# Acme — AI Data Analyst\nThis workspace is connected to http://localhost:8000.\n", - encoding="utf-8", - ) + import json as _json + + claude_dir = tmp_workspace / ".claude" + claude_dir.mkdir(parents=True, exist_ok=True) + settings = { + "hooks": { + "SessionStart": [{"hooks": [{"type": "command", "command": "da sync --quiet 2>/dev/null || true"}]}], + } + } + (claude_dir / "settings.json").write_text(_json.dumps(settings), encoding="utf-8") result = runner.invoke(app, ["analyst", "setup", "--server-url", "http://localhost:8000"]) assert result.exit_code == 1 assert "force" in result.output.lower() or "force" in (result.stderr or "").lower() def test_setup_proceeds_with_force(self, tmp_workspace): """--force bypasses existing-project detection.""" - (tmp_workspace / "CLAUDE.md").write_text( - "# Acme — AI Data Analyst\nThis workspace is connected to http://localhost:8000.\n", - encoding="utf-8", - ) + import json as _json + + claude_dir = tmp_workspace / ".claude" + claude_dir.mkdir(parents=True, exist_ok=True) + settings = { + "hooks": { + "SessionStart": [{"hooks": [{"type": "command", "command": "da sync --quiet 2>/dev/null || true"}]}], + } + } + (claude_dir / "settings.json").write_text(_json.dumps(settings), encoding="utf-8") with patch("cli.commands.analyst._connect_to_instance", return_value="tok"), \ patch("cli.commands.analyst._download_metadata"), \