mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-08-28 05:14:59 +00:00
Merge doctor-activation and init portability into the activation sprint
This commit is contained in:
commit
98edf48a5d
6 changed files with 549 additions and 16 deletions
|
|
@ -2,6 +2,7 @@ import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
@ -9,6 +10,21 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
from app.config import settings
|
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:
|
def _resolve_git_sha() -> str:
|
||||||
"""Return the short git SHA of the backend directory, or 'unknown'."""
|
"""Return the short git SHA of the backend directory, or 'unknown'."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -99,20 +115,27 @@ def create_app() -> FastAPI:
|
||||||
"compact_state", # --compact mode on state brief
|
"compact_state", # --compact mode on state brief
|
||||||
"worktrees", # /api/v5/worktrees
|
"worktrees", # /api/v5/worktrees
|
||||||
"worktree_binding", # claims can bind to worktrees + state drift summary
|
"worktree_binding", # claims can bind to worktrees + state drift summary
|
||||||
|
"activation_health", # /health includes DB mode + provider confidence
|
||||||
]
|
]
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
|
from app.config_loader import providers_status
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"git_sha": _git_sha,
|
"git_sha": _git_sha,
|
||||||
"capabilities": _capabilities,
|
"capabilities": _capabilities,
|
||||||
|
"database": _database_runtime_status(),
|
||||||
|
"providers": providers_status(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
from app.config_loader import providers_status
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from app.config_loader import providers_status
|
||||||
|
|
||||||
logger = logging.getLogger("smriti.startup")
|
logger = logging.getLogger("smriti.startup")
|
||||||
|
|
||||||
status = providers_status()
|
status = providers_status()
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ def test_health_returns_capabilities(client):
|
||||||
|
|
||||||
assert "capabilities" in data
|
assert "capabilities" in data
|
||||||
assert isinstance(data["capabilities"], list)
|
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):
|
def test_health_includes_required_capabilities(client):
|
||||||
|
|
@ -36,6 +39,22 @@ def test_health_includes_required_capabilities(client):
|
||||||
"compact_state",
|
"compact_state",
|
||||||
"worktrees",
|
"worktrees",
|
||||||
"worktree_binding",
|
"worktree_binding",
|
||||||
|
"activation_health",
|
||||||
]
|
]
|
||||||
for cap in required:
|
for cap in required:
|
||||||
assert cap in caps, f"Missing capability: {cap}"
|
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
|
||||||
|
|
|
||||||
|
|
@ -756,6 +756,7 @@ def format_doctor(report: dict) -> str:
|
||||||
"""Readable diagnostics for backend/runtime consistency."""
|
"""Readable diagnostics for backend/runtime consistency."""
|
||||||
backend = report.get("backend") or {}
|
backend = report.get("backend") or {}
|
||||||
local = report.get("local") or {}
|
local = report.get("local") or {}
|
||||||
|
cli = report.get("cli") or {}
|
||||||
checks = report.get("checks") or {}
|
checks = report.get("checks") or {}
|
||||||
hints = report.get("hints") 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(f"Backend error: {backend.get('error') or 'unknown'}")
|
||||||
parts.append("")
|
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("## Local repo")
|
||||||
parts.append(f"Branch: `{local.get('branch') or 'unknown'}`")
|
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'}`")
|
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))
|
parts.append("- missing capabilities: " + ", ".join(f"`{c}`" for c in missing))
|
||||||
else:
|
else:
|
||||||
parts.append("- missing capabilities: none")
|
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("")
|
||||||
|
|
||||||
parts.append("## Capabilities")
|
parts.append("## Capabilities")
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,12 @@ from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from importlib import metadata
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .client import SmritiClient, SmritiError
|
from .client import SmritiClient, SmritiError
|
||||||
|
|
@ -115,6 +119,7 @@ EXPECTED_HEALTH_CAPABILITIES = {
|
||||||
"compact_state",
|
"compact_state",
|
||||||
"worktrees",
|
"worktrees",
|
||||||
"worktree_binding",
|
"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)
|
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:
|
def _build_doctor_report(client: SmritiClient) -> dict:
|
||||||
"""Build a small diagnostics report without attempting repairs."""
|
"""Build a small diagnostics report without attempting repairs."""
|
||||||
local_sha = _git_output("rev-parse", "HEAD")
|
local_sha = _git_output("rev-parse", "HEAD")
|
||||||
|
|
@ -165,9 +289,12 @@ def _build_doctor_report(client: SmritiClient) -> dict:
|
||||||
"git_sha_short": local_short,
|
"git_sha_short": local_short,
|
||||||
"branch": local_branch,
|
"branch": local_branch,
|
||||||
},
|
},
|
||||||
|
"cli": _build_cli_info(),
|
||||||
"checks": {
|
"checks": {
|
||||||
"runtime_match": "unknown",
|
"runtime_match": "unknown",
|
||||||
"missing_capabilities": None,
|
"missing_capabilities": None,
|
||||||
|
"cli_path": "unknown",
|
||||||
|
"background_provider": "unknown",
|
||||||
},
|
},
|
||||||
"hints": [],
|
"hints": [],
|
||||||
}
|
}
|
||||||
|
|
@ -177,12 +304,23 @@ def _build_doctor_report(client: SmritiClient) -> dict:
|
||||||
except SmritiError as e:
|
except SmritiError as e:
|
||||||
report["backend"]["error"] = str(e)
|
report["backend"]["error"] = str(e)
|
||||||
report["hints"].append(
|
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
|
return report
|
||||||
|
|
||||||
capabilities = sorted(health.get("capabilities") or [])
|
capabilities = sorted(health.get("capabilities") or [])
|
||||||
backend_sha = health.get("git_sha")
|
backend_sha = health.get("git_sha")
|
||||||
|
database = health.get("database") or {}
|
||||||
|
providers = health.get("providers") or {}
|
||||||
missing = sorted(EXPECTED_HEALTH_CAPABILITIES - set(capabilities))
|
missing = sorted(EXPECTED_HEALTH_CAPABILITIES - set(capabilities))
|
||||||
match = _git_sha_matches(backend_sha, local_sha)
|
match = _git_sha_matches(backend_sha, local_sha)
|
||||||
|
|
||||||
|
|
@ -191,9 +329,25 @@ def _build_doctor_report(client: SmritiClient) -> dict:
|
||||||
"status": health.get("status"),
|
"status": health.get("status"),
|
||||||
"git_sha": backend_sha,
|
"git_sha": backend_sha,
|
||||||
"capabilities": capabilities,
|
"capabilities": capabilities,
|
||||||
|
"database": database,
|
||||||
|
"providers": providers,
|
||||||
"error": None,
|
"error": None,
|
||||||
})
|
})
|
||||||
report["checks"]["missing_capabilities"] = missing
|
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:
|
if match is True:
|
||||||
report["checks"]["runtime_match"] = "ok"
|
report["checks"]["runtime_match"] = "ok"
|
||||||
|
|
@ -219,6 +373,24 @@ def _build_doctor_report(client: SmritiClient) -> dict:
|
||||||
+ ", ".join(missing)
|
+ ", ".join(missing)
|
||||||
+ ". Pull/restart the backend if you expected newer behavior."
|
+ ". 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
|
return report
|
||||||
|
|
||||||
|
|
@ -922,9 +1094,8 @@ def cmd_init(client: SmritiClient, args: argparse.Namespace) -> None:
|
||||||
"""One-step agent onboarding: create space, install skill packs,
|
"""One-step agent onboarding: create space, install skill packs,
|
||||||
configure SessionStart hook. Idempotent — safe to run twice."""
|
configure SessionStart hook. Idempotent — safe to run twice."""
|
||||||
import json as _json
|
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
|
space_name = args.space
|
||||||
results: list[str] = []
|
results: list[str] = []
|
||||||
|
|
@ -997,10 +1168,7 @@ def cmd_init(client: SmritiClient, args: argparse.Namespace) -> None:
|
||||||
|
|
||||||
# 5. Generate SessionStart hook.
|
# 5. Generate SessionStart hook.
|
||||||
settings_path = Path(".claude/settings.json")
|
settings_path = Path(".claude/settings.json")
|
||||||
hook_command = (
|
hook_command = _build_session_start_hook_command(space_name, client.base_url)
|
||||||
f"backend/.venv/bin/smriti state {space_name} --preview 2>/dev/null "
|
|
||||||
f"|| echo 'Smriti backend not reachable. Start with: make dev-local'"
|
|
||||||
)
|
|
||||||
hook_entry = {
|
hook_entry = {
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": hook_command,
|
"command": hook_command,
|
||||||
|
|
@ -1021,24 +1189,46 @@ def cmd_init(client: SmritiClient, args: argparse.Namespace) -> None:
|
||||||
else:
|
else:
|
||||||
existing = {}
|
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")
|
results.append("SessionStart hook already configured → .claude/settings.json")
|
||||||
else:
|
else:
|
||||||
existing.setdefault("hooks", {})
|
existing["hooks"]["SessionStart"] = merged_session_start
|
||||||
existing["hooks"]["SessionStart"] = target_hooks["SessionStart"]
|
|
||||||
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
settings_path.write_text(
|
settings_path.write_text(
|
||||||
_json.dumps(existing, indent=2) + "\n", encoding="utf-8"
|
_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.
|
# 6. MCP config reminder.
|
||||||
|
mcp_config = {
|
||||||
|
"mcpServers": {
|
||||||
|
"smriti": {
|
||||||
|
"command": _smriti_mcp_executable(),
|
||||||
|
"env": {"SMRITI_API_URL": client.base_url},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
next_steps.append(
|
next_steps.append(
|
||||||
"Configure MCP in your host (if using Claude Code / Cursor / Windsurf):\n"
|
"Configure MCP in your host (if using Claude Code / Cursor / Windsurf):\n"
|
||||||
' {"mcpServers": {"smriti": {"command": "smriti-mcp", '
|
f" {_json.dumps(mcp_config)}"
|
||||||
'"env": {"SMRITI_API_URL": "http://localhost:8000"}}}}'
|
)
|
||||||
|
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.
|
# 7. Output.
|
||||||
if args.json:
|
if args.json:
|
||||||
|
|
|
||||||
|
|
@ -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))
|
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:
|
def _health(**overrides) -> dict:
|
||||||
base = {
|
base = {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"git_sha": "c470947",
|
"git_sha": "c470947",
|
||||||
"capabilities": sorted(cli_main.EXPECTED_HEALTH_CAPABILITIES),
|
"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)
|
base.update(overrides)
|
||||||
return base
|
return base
|
||||||
|
|
@ -49,15 +80,21 @@ def test_doctor_parser_wiring():
|
||||||
|
|
||||||
def test_doctor_report_ok(monkeypatch: pytest.MonkeyPatch):
|
def test_doctor_report_ok(monkeypatch: pytest.MonkeyPatch):
|
||||||
_patch_git(monkeypatch)
|
_patch_git(monkeypatch)
|
||||||
|
_patch_cli(monkeypatch)
|
||||||
client = _client(_health())
|
client = _client(_health())
|
||||||
|
|
||||||
report = cli_main._build_doctor_report(client)
|
report = cli_main._build_doctor_report(client)
|
||||||
|
|
||||||
assert report["backend"]["reachable"] is True
|
assert report["backend"]["reachable"] is True
|
||||||
assert report["backend"]["git_sha"] == "c470947"
|
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["local"]["git_sha_short"] == "c470947"
|
||||||
|
assert report["cli"]["path_matches_executable"] is True
|
||||||
assert report["checks"]["runtime_match"] == "ok"
|
assert report["checks"]["runtime_match"] == "ok"
|
||||||
assert report["checks"]["missing_capabilities"] == []
|
assert report["checks"]["missing_capabilities"] == []
|
||||||
|
assert report["checks"]["cli_path"] == "ok"
|
||||||
|
assert report["checks"]["background_provider"] == "ready"
|
||||||
assert report["hints"] == []
|
assert report["hints"] == []
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -65,6 +102,7 @@ def test_doctor_report_flags_mismatch_and_missing_capability(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
):
|
):
|
||||||
_patch_git(monkeypatch, sha="c470947abcdef")
|
_patch_git(monkeypatch, sha="c470947abcdef")
|
||||||
|
_patch_cli(monkeypatch)
|
||||||
capabilities = sorted(cli_main.EXPECTED_HEALTH_CAPABILITIES - {"worktree_binding"})
|
capabilities = sorted(cli_main.EXPECTED_HEALTH_CAPABILITIES - {"worktree_binding"})
|
||||||
client = _client(_health(git_sha="deadbee", capabilities=capabilities))
|
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"])
|
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(
|
def test_cmd_doctor_handles_unreachable_backend(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
):
|
):
|
||||||
_patch_git(monkeypatch)
|
_patch_git(monkeypatch)
|
||||||
|
_patch_cli(monkeypatch)
|
||||||
client = _client()
|
client = _client()
|
||||||
client.get_health.side_effect = SmritiError("Could not reach Smriti")
|
client.get_health.side_effect = SmritiError("Could not reach Smriti")
|
||||||
args = argparse.Namespace(json=False)
|
args = argparse.Namespace(json=False)
|
||||||
|
|
@ -91,11 +166,13 @@ def test_cmd_doctor_handles_unreachable_backend(
|
||||||
assert "# Smriti Doctor" in out
|
assert "# Smriti Doctor" in out
|
||||||
assert "Backend: unreachable" in out
|
assert "Backend: unreachable" in out
|
||||||
assert "missing capabilities: unknown" in out
|
assert "missing capabilities: unknown" in out
|
||||||
|
assert "make dev-local" in out
|
||||||
assert "Backend is not reachable" in out
|
assert "Backend is not reachable" in out
|
||||||
|
|
||||||
|
|
||||||
def test_cmd_doctor_json_outputs_report(monkeypatch: pytest.MonkeyPatch):
|
def test_cmd_doctor_json_outputs_report(monkeypatch: pytest.MonkeyPatch):
|
||||||
_patch_git(monkeypatch)
|
_patch_git(monkeypatch)
|
||||||
|
_patch_cli(monkeypatch)
|
||||||
client = _client(_health())
|
client = _client(_health())
|
||||||
args = argparse.Namespace(json=True)
|
args = argparse.Namespace(json=True)
|
||||||
captured: list[dict] = []
|
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]["backend"]["reachable"] is True
|
||||||
assert captured[0]["checks"]["runtime_match"] == "ok"
|
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
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,16 @@ def mock_client(monkeypatch):
|
||||||
client.base_url = "http://localhost:8000"
|
client.base_url = "http://localhost:8000"
|
||||||
client.list_spaces.return_value = []
|
client.list_spaces.return_value = []
|
||||||
monkeypatch.setattr(cli_main, "SmritiClient", lambda **kw: client)
|
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
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -38,6 +48,42 @@ def test_init_parser_wiring():
|
||||||
assert args.func is cli_main.cmd_init
|
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):
|
def test_init_creates_space_and_skill_packs(mock_client, tmp_path, monkeypatch):
|
||||||
"""Init with a fresh project: creates space, installs both skill packs."""
|
"""Init with a fresh project: creates space, installs both skill packs."""
|
||||||
monkeypatch.chdir(tmp_path)
|
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
|
# SessionStart hook was generated
|
||||||
settings = json.loads((tmp_path / ".claude" / "settings.json").read_text())
|
settings = json.loads((tmp_path / ".claude" / "settings.json").read_text())
|
||||||
assert "SessionStart" in settings.get("hooks", {})
|
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):
|
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
|
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(
|
def test_init_merges_into_existing_settings_json(
|
||||||
mock_client, tmp_path, monkeypatch
|
mock_client, tmp_path, monkeypatch
|
||||||
):
|
):
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue