mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-08-28 05:14:59 +00:00
Make init startup hooks portable
This commit is contained in:
parent
448484c4c3
commit
ae9d367c08
2 changed files with 198 additions and 12 deletions
|
|
@ -51,6 +51,7 @@ from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
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:
|
def _background_provider_check(providers: dict) -> str:
|
||||||
bg = providers.get("background_intelligence") if providers else None
|
bg = providers.get("background_intelligence") if providers else None
|
||||||
if not bg:
|
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,
|
"""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] = []
|
||||||
|
|
@ -1098,10 +1151,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)
|
||||||
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,
|
||||||
|
|
@ -1122,16 +1172,27 @@ 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.
|
||||||
next_steps.append(
|
next_steps.append(
|
||||||
|
|
@ -1139,7 +1200,11 @@ def cmd_init(client: SmritiClient, args: argparse.Namespace) -> None:
|
||||||
' {"mcpServers": {"smriti": {"command": "smriti-mcp", '
|
' {"mcpServers": {"smriti": {"command": "smriti-mcp", '
|
||||||
'"env": {"SMRITI_API_URL": "http://localhost:8000"}}}}'
|
'"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.
|
# 7. Output.
|
||||||
if args.json:
|
if args.json:
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ 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",
|
||||||
|
)
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -38,6 +43,38 @@ 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")
|
||||||
|
|
||||||
|
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):
|
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 +104,10 @@ 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 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 +184,87 @@ 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 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(
|
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