Merge doctor-activation and init portability into the activation sprint

This commit is contained in:
Himanshu Dongre 2026-05-17 10:50:01 +05:30
commit 98edf48a5d
6 changed files with 549 additions and 16 deletions

View file

@ -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()

View file

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

View file

@ -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")

View file

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

View file

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

View file

@ -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
):