diff --git a/cli/smriti_cli/main.py b/cli/smriti_cli/main.py index a397953..74637b9 100644 --- a/cli/smriti_cli/main.py +++ b/cli/smriti_cli/main.py @@ -51,6 +51,7 @@ from __future__ import annotations import argparse import json import os +import shlex import shutil import subprocess import sys @@ -188,6 +189,59 @@ def _build_cli_info() -> dict: } +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 _build_session_start_hook_command(space_name: str) -> str: + command = " ".join( + [ + shlex.quote(_smriti_hook_executable()), + "state", + shlex.quote(space_name), + "--compact", + "2>/dev/null", + "||", + "echo", + shlex.quote( + "Smriti backend not reachable. Start with: make dev-local" + ), + ] + ) + 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 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: @@ -1023,9 +1077,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] = [] @@ -1098,10 +1151,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) hook_entry = { "type": "command", "command": hook_command, @@ -1122,16 +1172,27 @@ 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. next_steps.append( @@ -1139,7 +1200,11 @@ def cmd_init(client: SmritiClient, args: argparse.Namespace) -> None: ' {"mcpServers": {"smriti": {"command": "smriti-mcp", ' '"env": {"SMRITI_API_URL": "http://localhost:8000"}}}}' ) - next_steps.append(f"Start working:\n smriti state {space_name}") + next_steps.append( + "Verify activation:\n" + " smriti doctor\n" + f" smriti state {space_name} --compact" + ) # 7. Output. if args.json: diff --git a/cli/tests/test_init.py b/cli/tests/test_init.py index 314501c..8d88b24 100644 --- a/cli/tests/test_init.py +++ b/cli/tests/test_init.py @@ -26,6 +26,11 @@ 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", + ) return client @@ -38,6 +43,38 @@ 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") + + assert command.startswith( + "'/Applications/Smriti Tools/bin/smriti' 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' 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 +104,10 @@ 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 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 +184,87 @@ 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 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 state p --compact" + ) + for entry in entries[1:] + ) + + def test_init_merges_into_existing_settings_json( mock_client, tmp_path, monkeypatch ):