diff --git a/backend/app/main.py b/backend/app/main.py index 9a574f5..c599dba 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,6 +2,7 @@ import logging import pathlib import re import subprocess +from urllib.parse import urlsplit from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -9,6 +10,21 @@ from fastapi.middleware.cors import CORSMiddleware from app.config import settings +def _database_runtime_status() -> dict: + """Return safe-to-expose database mode metadata for diagnostics.""" + resolved_url = settings.resolved_database_url + status = { + "mode": settings.db_mode, + "url_scheme": urlsplit(resolved_url).scheme or "unknown", + } + if settings.db_mode == "local": + status["local_db_path"] = str(settings.local_db_path) + else: + # Do not expose DATABASE_URL: it may contain credentials. + status["database_url_set"] = bool(settings.database_url.strip()) + return status + + def _resolve_git_sha() -> str: """Return the short git SHA of the backend directory, or 'unknown'.""" try: @@ -99,20 +115,27 @@ def create_app() -> FastAPI: "compact_state", # --compact mode on state brief "worktrees", # /api/v5/worktrees "worktree_binding", # claims can bind to worktrees + state drift summary + "activation_health", # /health includes DB mode + provider confidence ] @app.get("/health") async def health_check(): + from app.config_loader import providers_status + return { "status": "ok", "git_sha": _git_sha, "capabilities": _capabilities, + "database": _database_runtime_status(), + "providers": providers_status(), } @app.on_event("startup") async def startup_event(): - from app.config_loader import providers_status import logging + + from app.config_loader import providers_status + logger = logging.getLogger("smriti.startup") status = providers_status() diff --git a/backend/tests/integration/test_health.py b/backend/tests/integration/test_health.py index c3a7cf4..5d6feb3 100644 --- a/backend/tests/integration/test_health.py +++ b/backend/tests/integration/test_health.py @@ -18,6 +18,9 @@ def test_health_returns_capabilities(client): assert "capabilities" in data assert isinstance(data["capabilities"], list) + assert data["database"]["mode"] in {"local", "postgres"} + assert "url_scheme" in data["database"] + assert "background_intelligence" in data["providers"] def test_health_includes_required_capabilities(client): @@ -36,6 +39,22 @@ def test_health_includes_required_capabilities(client): "compact_state", "worktrees", "worktree_binding", + "activation_health", ] for cap in required: assert cap in caps, f"Missing capability: {cap}" + + +def test_health_exposes_safe_database_status(client): + """Database status is useful for doctor without exposing credentials.""" + r = client.get("/health") + data = r.json() + db = data["database"] + + assert db["mode"] in {"local", "postgres"} + if db["mode"] == "local": + assert db["local_db_path"] + assert "database_url_set" not in db + else: + assert "database_url_set" in db + assert "DATABASE_URL" not in db diff --git a/cli/smriti_cli/formatters.py b/cli/smriti_cli/formatters.py index cce1545..6bfd6d8 100644 --- a/cli/smriti_cli/formatters.py +++ b/cli/smriti_cli/formatters.py @@ -756,6 +756,7 @@ def format_doctor(report: dict) -> str: """Readable diagnostics for backend/runtime consistency.""" backend = report.get("backend") or {} local = report.get("local") or {} + cli = report.get("cli") or {} checks = report.get("checks") or {} hints = report.get("hints") or [] @@ -771,6 +772,46 @@ def format_doctor(report: dict) -> str: parts.append(f"Backend error: {backend.get('error') or 'unknown'}") parts.append("") + parts.append("## Runtime") + database = backend.get("database") or {} + if database: + mode = database.get("mode") or "unknown" + parts.append(f"Database mode: `{mode}`") + if mode == "local": + parts.append(f"Local DB path: `{database.get('local_db_path') or 'unknown'}`") + elif mode == "postgres": + configured = database.get("database_url_set") + configured_label = "yes" if configured else "using default" + parts.append(f"Postgres DATABASE_URL set: {configured_label}") + if database.get("url_scheme"): + parts.append(f"Database URL scheme: `{database.get('url_scheme')}`") + else: + parts.append("Database mode: unknown") + + providers = backend.get("providers") or {} + bg = providers.get("background_intelligence") if providers else None + if bg: + status = "ready" if bg.get("configured") else "mock/disabled" + provider = bg.get("provider") or "unknown" + model = bg.get("model") or "unknown" + parts.append(f"Background intelligence: {status} (`{provider}` / `{model}`)") + else: + parts.append("Background intelligence: unknown") + parts.append("") + + parts.append("## CLI") + parts.append(f"Executable: `{cli.get('executable') or 'unknown'}`") + parts.append(f"`smriti` on PATH: `{cli.get('path_entry') or 'not found'}`") + parts.append(f"Package version: `{cli.get('package_version') or 'unknown'}`") + path_match = cli.get("path_matches_executable") + if path_match is True: + parts.append("PATH matches executable: yes") + elif path_match is False: + parts.append("PATH matches executable: no") + else: + parts.append("PATH matches executable: unknown") + parts.append("") + parts.append("## Local repo") parts.append(f"Branch: `{local.get('branch') or 'unknown'}`") parts.append(f"HEAD: `{local.get('git_sha_short') or local.get('git_sha') or 'unknown'}`") @@ -786,6 +827,8 @@ def format_doctor(report: dict) -> str: parts.append("- missing capabilities: " + ", ".join(f"`{c}`" for c in missing)) else: parts.append("- missing capabilities: none") + parts.append(f"- CLI path: {checks.get('cli_path') or 'unknown'}") + parts.append(f"- background provider: {checks.get('background_provider') or 'unknown'}") parts.append("") parts.append("## Capabilities") diff --git a/cli/smriti_cli/main.py b/cli/smriti_cli/main.py index 5863c08..ef2c33a 100644 --- a/cli/smriti_cli/main.py +++ b/cli/smriti_cli/main.py @@ -51,8 +51,12 @@ from __future__ import annotations import argparse import json import os +import shlex +import shutil import subprocess import sys +from importlib import metadata +from pathlib import Path from typing import Any from .client import SmritiClient, SmritiError @@ -115,6 +119,7 @@ EXPECTED_HEALTH_CAPABILITIES = { "compact_state", "worktrees", "worktree_binding", + "activation_health", } @@ -142,6 +147,125 @@ def _git_sha_matches(backend_sha: str | None, local_sha: str | None) -> bool | N return local.startswith(backend) or backend.startswith(local) +def _resolve_executable_path(value: str | None) -> str | None: + """Resolve a command path for diagnostics without requiring it to exist.""" + if not value: + return None + candidate = Path(value) + if not candidate.is_absolute(): + found = shutil.which(value) + if found: + candidate = Path(found) + try: + return str(candidate.expanduser().resolve()) + except OSError: + return str(candidate) + + +def _package_version() -> str | None: + try: + return metadata.version("smriti-cli") + except metadata.PackageNotFoundError: + return None + + +def _build_cli_info() -> dict: + """Return local CLI-source details so stale PATH wrappers are visible.""" + invoked = _resolve_executable_path(sys.argv[0]) + path_entry = _resolve_executable_path(shutil.which("smriti")) + match: bool | None + if invoked and path_entry: + match = invoked == path_entry + elif invoked or path_entry: + match = False + else: + match = None + + return { + "executable": invoked, + "path_entry": path_entry, + "path_matches_executable": match, + "package_version": _package_version(), + } + + +def _smriti_hook_executable() -> str: + """Choose a portable smriti executable for generated startup hooks. + + Prefer the script that is running `smriti init` when it looks like the + installed `smriti` entry point. Fall back to the first `smriti` on PATH, + then to the bare command. We deliberately avoid repo-relative paths such + as `backend/.venv/bin/smriti` because init runs inside the user's target + project, not necessarily inside the Smriti checkout. + """ + invoked = _resolve_executable_path(sys.argv[0]) + if invoked: + invoked_path = Path(invoked) + if invoked_path.name == "smriti" and os.access(invoked_path, os.X_OK): + return invoked + + path_entry = _resolve_executable_path(shutil.which("smriti")) + return path_entry or "smriti" + + +def _smriti_mcp_executable() -> str: + """Choose a practical MCP server executable for generated config hints.""" + smriti_exe = _smriti_hook_executable() + if smriti_exe != "smriti": + sibling = Path(smriti_exe).with_name("smriti-mcp") + if sibling.exists() and os.access(sibling, os.X_OK): + return str(sibling) + + path_entry = _resolve_executable_path(shutil.which("smriti-mcp")) + return path_entry or "smriti-mcp" + + +def _build_session_start_hook_command(space_name: str, api_url: str | None = None) -> str: + args = [shlex.quote(_smriti_hook_executable())] + if api_url: + args.extend(["--api-url", shlex.quote(api_url)]) + args.extend( + [ + "state", + shlex.quote(space_name), + "--compact", + "2>/dev/null", + "||", + "echo", + shlex.quote( + "Smriti backend not reachable. Start with: make dev-local" + ), + ] + ) + command = " ".join( + args + ) + return command + + +def _is_smriti_session_start_entry(entry: Any) -> bool: + if not isinstance(entry, dict): + return False + hooks = entry.get("hooks") + if not isinstance(hooks, list): + return False + for hook in hooks: + if not isinstance(hook, dict): + continue + command = hook.get("command") + normalized = command.replace("'", "").replace('"', "") if isinstance(command, str) else "" + if "smriti" in normalized and " state " in normalized: + return True + return False + + +def _background_provider_check(providers: dict) -> str: + bg = providers.get("background_intelligence") if providers else None + if not bg: + return "unknown" + return "ready" if bg.get("configured") else "mock_or_disabled" + + def _build_doctor_report(client: SmritiClient) -> dict: """Build a small diagnostics report without attempting repairs.""" local_sha = _git_output("rev-parse", "HEAD") @@ -165,9 +289,12 @@ def _build_doctor_report(client: SmritiClient) -> dict: "git_sha_short": local_short, "branch": local_branch, }, + "cli": _build_cli_info(), "checks": { "runtime_match": "unknown", "missing_capabilities": None, + "cli_path": "unknown", + "background_provider": "unknown", }, "hints": [], } @@ -177,12 +304,23 @@ def _build_doctor_report(client: SmritiClient) -> dict: except SmritiError as e: report["backend"]["error"] = str(e) report["hints"].append( - "Backend is not reachable; start Smriti and rerun `smriti doctor`." + "Backend is not reachable; start Smriti with `make dev-local` " + "for solo/local mode or `make dev-postgres` for shared/team mode, " + "then rerun `smriti doctor`." ) + cli_info = report.get("cli") or {} + if cli_info.get("path_matches_executable") is False: + report["checks"]["cli_path"] = "mismatch" + report["hints"].append( + "The `smriti` on PATH differs from this doctor executable. " + "Activate the intended environment or update PATH before daily use." + ) return report capabilities = sorted(health.get("capabilities") or []) backend_sha = health.get("git_sha") + database = health.get("database") or {} + providers = health.get("providers") or {} missing = sorted(EXPECTED_HEALTH_CAPABILITIES - set(capabilities)) match = _git_sha_matches(backend_sha, local_sha) @@ -191,9 +329,25 @@ def _build_doctor_report(client: SmritiClient) -> dict: "status": health.get("status"), "git_sha": backend_sha, "capabilities": capabilities, + "database": database, + "providers": providers, "error": None, }) report["checks"]["missing_capabilities"] = missing + report["checks"]["background_provider"] = _background_provider_check(providers) + + cli_info = report.get("cli") or {} + cli_match = cli_info.get("path_matches_executable") + if cli_match is True: + report["checks"]["cli_path"] = "ok" + elif cli_match is False: + report["checks"]["cli_path"] = "mismatch" + report["hints"].append( + "The `smriti` on PATH differs from this doctor executable. " + "Activate the intended environment or update PATH before daily use." + ) + else: + report["checks"]["cli_path"] = "unknown" if match is True: report["checks"]["runtime_match"] = "ok" @@ -219,6 +373,24 @@ def _build_doctor_report(client: SmritiClient) -> dict: + ", ".join(missing) + ". Pull/restart the backend if you expected newer behavior." ) + if not database: + report["hints"].append( + "Backend /health did not report database mode; restart after updating " + "the backend if you expected activation diagnostics." + ) + + bg = providers.get("background_intelligence") if providers else None + if not bg: + report["hints"].append( + "Backend /health did not report provider status; provider/mock " + "confidence is unknown." + ) + elif not bg.get("configured"): + provider = bg.get("provider") or "background" + report["hints"].append( + f"Background intelligence provider `{provider}` is not configured; " + "checkpoint extract/review flows may use mock output or fail." + ) return report @@ -922,9 +1094,8 @@ def cmd_init(client: SmritiClient, args: argparse.Namespace) -> None: """One-step agent onboarding: create space, install skill packs, configure SessionStart hook. Idempotent — safe to run twice.""" import json as _json - from pathlib import Path - from .skill_pack import get_version, install as install_skill, renderer + from .skill_pack import install as install_skill space_name = args.space results: list[str] = [] @@ -997,10 +1168,7 @@ def cmd_init(client: SmritiClient, args: argparse.Namespace) -> None: # 5. Generate SessionStart hook. settings_path = Path(".claude/settings.json") - hook_command = ( - f"backend/.venv/bin/smriti state {space_name} --preview 2>/dev/null " - f"|| echo 'Smriti backend not reachable. Start with: make dev-local'" - ) + hook_command = _build_session_start_hook_command(space_name, client.base_url) hook_entry = { "type": "command", "command": hook_command, @@ -1021,24 +1189,46 @@ def cmd_init(client: SmritiClient, args: argparse.Namespace) -> None: else: existing = {} - if "hooks" in existing and "SessionStart" in existing.get("hooks", {}): + existing.setdefault("hooks", {}) + existing_session_start = existing["hooks"].get("SessionStart") + if not isinstance(existing_session_start, list): + existing_session_start = [] + + non_smriti_hooks = [ + entry for entry in existing_session_start + if not _is_smriti_session_start_entry(entry) + ] + merged_session_start = non_smriti_hooks + target_hooks["SessionStart"] + + if existing_session_start == merged_session_start: results.append("SessionStart hook already configured → .claude/settings.json") else: - existing.setdefault("hooks", {}) - existing["hooks"]["SessionStart"] = target_hooks["SessionStart"] + existing["hooks"]["SessionStart"] = merged_session_start settings_path.parent.mkdir(parents=True, exist_ok=True) settings_path.write_text( _json.dumps(existing, indent=2) + "\n", encoding="utf-8" ) - results.append("SessionStart hook configured → .claude/settings.json") + action = "updated" if existing_session_start else "configured" + results.append(f"SessionStart hook {action} → .claude/settings.json") # 6. MCP config reminder. + mcp_config = { + "mcpServers": { + "smriti": { + "command": _smriti_mcp_executable(), + "env": {"SMRITI_API_URL": client.base_url}, + } + } + } next_steps.append( "Configure MCP in your host (if using Claude Code / Cursor / Windsurf):\n" - ' {"mcpServers": {"smriti": {"command": "smriti-mcp", ' - '"env": {"SMRITI_API_URL": "http://localhost:8000"}}}}' + f" {_json.dumps(mcp_config)}" + ) + next_steps.append( + "Verify activation:\n" + " smriti doctor\n" + f" smriti state {space_name} --compact" ) - next_steps.append(f"Start working:\n smriti state {space_name}") # 7. Output. if args.json: diff --git a/cli/tests/test_doctor_cli.py b/cli/tests/test_doctor_cli.py index da269e8..9b02479 100644 --- a/cli/tests/test_doctor_cli.py +++ b/cli/tests/test_doctor_cli.py @@ -28,11 +28,42 @@ def _patch_git(monkeypatch: pytest.MonkeyPatch, *, sha: str = "c470947abcdef", b monkeypatch.setattr(cli_main, "_git_output", lambda *args: values.get(args)) +def _patch_cli( + monkeypatch: pytest.MonkeyPatch, + *, + executable: str = "/repo/backend/.venv/bin/smriti", + path_entry: str = "/repo/backend/.venv/bin/smriti", + version: str = "0.1.0", +) -> None: + monkeypatch.setattr( + cli_main, + "_build_cli_info", + lambda: { + "executable": executable, + "path_entry": path_entry, + "path_matches_executable": executable == path_entry, + "package_version": version, + }, + ) + + def _health(**overrides) -> dict: base = { "status": "ok", "git_sha": "c470947", "capabilities": sorted(cli_main.EXPECTED_HEALTH_CAPABILITIES), + "database": { + "mode": "local", + "url_scheme": "sqlite", + "local_db_path": "/Users/test/.smriti/smriti.db", + }, + "providers": { + "background_intelligence": { + "provider": "openai", + "model": "gpt-4o-mini", + "configured": True, + } + }, } base.update(overrides) return base @@ -49,15 +80,21 @@ def test_doctor_parser_wiring(): def test_doctor_report_ok(monkeypatch: pytest.MonkeyPatch): _patch_git(monkeypatch) + _patch_cli(monkeypatch) client = _client(_health()) report = cli_main._build_doctor_report(client) assert report["backend"]["reachable"] is True assert report["backend"]["git_sha"] == "c470947" + assert report["backend"]["database"]["mode"] == "local" + assert report["backend"]["providers"]["background_intelligence"]["configured"] is True assert report["local"]["git_sha_short"] == "c470947" + assert report["cli"]["path_matches_executable"] is True assert report["checks"]["runtime_match"] == "ok" assert report["checks"]["missing_capabilities"] == [] + assert report["checks"]["cli_path"] == "ok" + assert report["checks"]["background_provider"] == "ready" assert report["hints"] == [] @@ -65,6 +102,7 @@ def test_doctor_report_flags_mismatch_and_missing_capability( monkeypatch: pytest.MonkeyPatch, ): _patch_git(monkeypatch, sha="c470947abcdef") + _patch_cli(monkeypatch) capabilities = sorted(cli_main.EXPECTED_HEALTH_CAPABILITIES - {"worktree_binding"}) client = _client(_health(git_sha="deadbee", capabilities=capabilities)) @@ -76,11 +114,48 @@ def test_doctor_report_flags_mismatch_and_missing_capability( assert any("missing capabilities" in hint for hint in report["hints"]) +def test_doctor_report_flags_cli_path_mismatch(monkeypatch: pytest.MonkeyPatch): + _patch_git(monkeypatch) + _patch_cli( + monkeypatch, + executable="/repo/backend/.venv/bin/smriti", + path_entry="/Users/test/.local/bin/smriti", + ) + client = _client(_health()) + + report = cli_main._build_doctor_report(client) + + assert report["checks"]["cli_path"] == "mismatch" + assert any("PATH differs" in hint for hint in report["hints"]) + + +def test_doctor_report_flags_background_mock_risk(monkeypatch: pytest.MonkeyPatch): + _patch_git(monkeypatch) + _patch_cli(monkeypatch) + client = _client( + _health( + providers={ + "background_intelligence": { + "provider": "openai", + "model": "gpt-4o-mini", + "configured": False, + } + } + ) + ) + + report = cli_main._build_doctor_report(client) + + assert report["checks"]["background_provider"] == "mock_or_disabled" + assert any("not configured" in hint for hint in report["hints"]) + + def test_cmd_doctor_handles_unreachable_backend( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ): _patch_git(monkeypatch) + _patch_cli(monkeypatch) client = _client() client.get_health.side_effect = SmritiError("Could not reach Smriti") args = argparse.Namespace(json=False) @@ -91,11 +166,13 @@ def test_cmd_doctor_handles_unreachable_backend( assert "# Smriti Doctor" in out assert "Backend: unreachable" in out assert "missing capabilities: unknown" in out + assert "make dev-local" in out assert "Backend is not reachable" in out def test_cmd_doctor_json_outputs_report(monkeypatch: pytest.MonkeyPatch): _patch_git(monkeypatch) + _patch_cli(monkeypatch) client = _client(_health()) args = argparse.Namespace(json=True) captured: list[dict] = [] @@ -108,3 +185,23 @@ def test_cmd_doctor_json_outputs_report(monkeypatch: pytest.MonkeyPatch): assert captured[0]["backend"]["reachable"] is True assert captured[0]["checks"]["runtime_match"] == "ok" + + +def test_cmd_doctor_prints_activation_details( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +): + _patch_git(monkeypatch) + _patch_cli(monkeypatch) + client = _client(_health()) + args = argparse.Namespace(json=False) + + cli_main.cmd_doctor(client, args) + + out = capsys.readouterr().out + assert "## Runtime" in out + assert "Database mode: `local`" in out + assert "Local DB path: `/Users/test/.smriti/smriti.db`" in out + assert "Background intelligence: ready (`openai` / `gpt-4o-mini`)" in out + assert "## CLI" in out + assert "PATH matches executable: yes" in out diff --git a/cli/tests/test_init.py b/cli/tests/test_init.py index 314501c..f344069 100644 --- a/cli/tests/test_init.py +++ b/cli/tests/test_init.py @@ -26,6 +26,16 @@ def mock_client(monkeypatch): client.base_url = "http://localhost:8000" client.list_spaces.return_value = [] monkeypatch.setattr(cli_main, "SmritiClient", lambda **kw: client) + monkeypatch.setattr( + cli_main, + "_smriti_hook_executable", + lambda: "/opt/smriti/bin/smriti", + ) + monkeypatch.setattr( + cli_main, + "_smriti_mcp_executable", + lambda: "/opt/smriti/bin/smriti-mcp", + ) return client @@ -38,6 +48,42 @@ def test_init_parser_wiring(): assert args.func is cli_main.cmd_init +def test_session_start_hook_command_shell_quotes(monkeypatch): + monkeypatch.setattr( + cli_main, + "_smriti_hook_executable", + lambda: "/Applications/Smriti Tools/bin/smriti", + ) + + command = cli_main._build_session_start_hook_command( + "my project", + "http://localhost:8000", + ) + + assert command.startswith( + "'/Applications/Smriti Tools/bin/smriti' --api-url " + "http://localhost:8000 state 'my project' --compact" + ) + assert "backend/.venv/bin/smriti" not in command + + +def test_smriti_session_start_detector_handles_quoted_executable(): + entry = { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": ( + "'/Applications/Smriti Tools/bin/smriti' --api-url " + "http://localhost:8000 state p --compact 2>/dev/null" + ), + } + ], + } + + assert cli_main._is_smriti_session_start_entry(entry) is True + + def test_init_creates_space_and_skill_packs(mock_client, tmp_path, monkeypatch): """Init with a fresh project: creates space, installs both skill packs.""" monkeypatch.chdir(tmp_path) @@ -67,7 +113,13 @@ def test_init_creates_space_and_skill_packs(mock_client, tmp_path, monkeypatch): # SessionStart hook was generated settings = json.loads((tmp_path / ".claude" / "settings.json").read_text()) assert "SessionStart" in settings.get("hooks", {}) - assert "test-project" in settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] + command = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] + assert command.startswith( + "/opt/smriti/bin/smriti --api-url " + "http://localhost:8000 state test-project --compact" + ) + assert "backend/.venv/bin/smriti" not in command + assert "--preview" not in command def test_init_connects_existing_space(mock_client, tmp_path, monkeypatch): @@ -144,6 +196,115 @@ def test_init_idempotent_second_run(mock_client, tmp_path, monkeypatch): assert len(settings["hooks"]["SessionStart"]) == 3 +def test_init_updates_stale_smriti_session_start_hook( + mock_client, tmp_path, monkeypatch +): + """Repo-relative / preview-mode hooks should be upgraded in place.""" + monkeypatch.chdir(tmp_path) + mock_client.resolve_space.return_value = {"id": "uuid", "name": "p"} + + settings_dir = tmp_path / ".claude" + settings_dir.mkdir() + settings_file = settings_dir / "settings.json" + settings_file.write_text( + json.dumps({ + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": ( + "backend/.venv/bin/smriti state p --preview " + "2>/dev/null || echo old" + ), + } + ], + } + ] + } + }) + ) + + args = cli_main._build_parser().parse_args(["init", "p"]) + args.api_url = None + cli_main.cmd_init(mock_client, args) + + settings = json.loads(settings_file.read_text()) + commands = [ + entry["hooks"][0]["command"] + for entry in settings["hooks"]["SessionStart"] + ] + assert len(commands) == 3 + assert all( + command.startswith( + "/opt/smriti/bin/smriti --api-url http://localhost:8000 state p --compact" + ) + for command in commands + ) + assert all("backend/.venv/bin/smriti" not in command for command in commands) + assert all("--preview" not in command for command in commands) + + +def test_init_preserves_unrelated_session_start_hooks( + mock_client, tmp_path, monkeypatch +): + """User-managed SessionStart hooks should survive init.""" + monkeypatch.chdir(tmp_path) + mock_client.resolve_space.return_value = {"id": "uuid", "name": "p"} + + settings_dir = tmp_path / ".claude" + settings_dir.mkdir() + settings_file = settings_dir / "settings.json" + user_hook = { + "matcher": "startup", + "hooks": [{"type": "command", "command": "echo user hook"}], + } + settings_file.write_text(json.dumps({"hooks": {"SessionStart": [user_hook]}})) + + args = cli_main._build_parser().parse_args(["init", "p"]) + args.api_url = None + cli_main.cmd_init(mock_client, args) + + settings = json.loads(settings_file.read_text()) + entries = settings["hooks"]["SessionStart"] + assert entries[0] == user_hook + assert len(entries) == 4 + assert any( + entry["hooks"][0]["command"].startswith( + "/opt/smriti/bin/smriti --api-url http://localhost:8000 state p --compact" + ) + for entry in entries[1:] + ) + + +def test_init_json_preserves_api_url_for_hooks_and_mcp( + mock_client, tmp_path, monkeypatch, capsys +): + """Custom backend URLs should survive generated hooks and MCP hints.""" + monkeypatch.chdir(tmp_path) + mock_client.base_url = "http://127.0.0.1:8999" + mock_client.resolve_space.return_value = {"id": "uuid", "name": "p"} + + args = cli_main._build_parser().parse_args([ + "--api-url", + "http://127.0.0.1:8999", + "init", + "p", + "--json", + ]) + cli_main.cmd_init(mock_client, args) + + payload = json.loads(capsys.readouterr().out) + settings = json.loads((tmp_path / ".claude" / "settings.json").read_text()) + command = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] + + assert "--api-url http://127.0.0.1:8999" in command + assert any("/opt/smriti/bin/smriti-mcp" in step for step in payload["next_steps"]) + assert any("http://127.0.0.1:8999" in step for step in payload["next_steps"]) + + def test_init_merges_into_existing_settings_json( mock_client, tmp_path, monkeypatch ):