Files
zeekay ebb04882d6 feat: hanzo-dev Python TUI + LSP + hooks + sandbox + vim + git + multi-auth + full parity
Rename hanzo-repl → hanzo-dev to match @hanzo/dev (Rust/TS).

New packages:
- hanzo-lsp: async LSP client (diagnostics, go-to-def, references, context enrichment)
- hanzo-hooks: pre/post tool use hooks (shell scripts, exit-code deny)
- hanzo-sandbox: container detection, filesystem isolation, Linux unshare

hanzo-dev TUI:
- Vim keybindings (Normal/Insert/Visual/Command modes, 37 tests)
- Git slash commands (/branch /commit /worktree /diff /stash, 13 tests)
- All slash commands at parity: /model /permissions /clear /resume /memory
  /init /export /session /plan /solve /code /auto /fast /remote-control
- Multi-provider auth: Anthropic OAuth, OpenAI device code, Hanzo PKCE, auto-detect

Auth:
- login_with_anthropic() — PKCE via console.hanzo.ai
- login_with_openai() — device code flow via auth.openai.com
- login_auto() — env var detection + saved credential fallback
- OAuthCredentialStore supports provider keys (hanzo/anthropic/openai)

Parity test suite (38 tests): SSE, MCP normalization, permissions, compaction,
token usage, PKCE RFC vector, config, backoff, session roundtrip, hooks.

348 tests total, 0 failures across Python + Rust + Universe E2E.
2026-04-01 10:41:46 -07:00

162 lines
5.8 KiB
Python

"""Tests for hanzo_hooks."""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from hanzo_hooks import HookConfig, HookEvent, HookRunner, HookRunResult
class TestHookEvent:
def test_values(self):
assert HookEvent.PreToolUse.value == "PreToolUse"
assert HookEvent.PostToolUse.value == "PostToolUse"
class TestHookRunResult:
def test_allow(self):
r = HookRunResult.allow()
assert not r.denied
assert r.messages == []
def test_allow_with_messages(self):
r = HookRunResult.allow(["msg1", "msg2"])
assert not r.denied
assert r.messages == ["msg1", "msg2"]
def test_denied(self):
r = HookRunResult(denied=True, messages=["blocked"])
assert r.denied
assert r.messages == ["blocked"]
def test_to_permission_outcome_allow(self):
r = HookRunResult.allow()
outcome = r.to_permission_outcome()
assert outcome.allowed is True
def test_to_permission_outcome_deny(self):
r = HookRunResult(denied=True, messages=["reason A", "reason B"])
outcome = r.to_permission_outcome()
assert outcome.allowed is False
assert "reason A" in outcome.reason
assert "reason B" in outcome.reason
class TestHookConfig:
def test_from_dict(self):
cfg = HookConfig.from_dict({
"pre_tool_use": ["echo pre"],
"post_tool_use": ["echo post"],
})
assert cfg.pre_tool_use == ["echo pre"]
assert cfg.post_tool_use == ["echo post"]
def test_from_dict_camel_case(self):
cfg = HookConfig.from_dict({
"PreToolUse": ["echo pre"],
"PostToolUse": ["echo post"],
})
assert cfg.pre_tool_use == ["echo pre"]
assert cfg.post_tool_use == ["echo post"]
def test_from_dict_empty(self):
cfg = HookConfig.from_dict({})
assert cfg.pre_tool_use == []
assert cfg.post_tool_use == []
def test_from_json(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump({"hooks": {"pre_tool_use": ["echo hi"]}}, f)
f.flush()
cfg = HookConfig.from_json(f.name)
assert cfg.pre_tool_use == ["echo hi"]
assert cfg.post_tool_use == []
Path(f.name).unlink()
class TestHookRunner:
def test_no_commands_returns_allow(self):
runner = HookRunner(HookConfig())
result = runner.run_pre_tool_use("Read", '{"path":"README.md"}')
assert not result.denied
assert result.messages == []
def test_exit_zero_captures_stdout(self):
runner = HookRunner(HookConfig(pre_tool_use=["printf 'pre ok'"]))
result = runner.run_pre_tool_use("Read", '{"path":"README.md"}')
assert not result.denied
assert result.messages == ["pre ok"]
def test_exit_two_denies(self):
runner = HookRunner(HookConfig(pre_tool_use=["printf 'blocked'; exit 2"]))
result = runner.run_pre_tool_use("Bash", '{"command":"pwd"}')
assert result.denied
assert result.messages == ["blocked"]
def test_exit_two_without_stdout_uses_default_message(self):
runner = HookRunner(HookConfig(pre_tool_use=["exit 2"]))
result = runner.run_pre_tool_use("Bash", '{"command":"pwd"}')
assert result.denied
assert "denied" in result.messages[0]
assert "Bash" in result.messages[0]
def test_other_exit_code_warns_but_allows(self):
runner = HookRunner(HookConfig(pre_tool_use=["printf 'oops'; exit 1"]))
result = runner.run_pre_tool_use("Edit", '{"file":"lib.py"}')
assert not result.denied
assert len(result.messages) == 1
assert "allowing tool execution to continue" in result.messages[0]
def test_short_circuit_on_deny(self):
runner = HookRunner(HookConfig(pre_tool_use=[
"printf 'first ok'",
"printf 'deny'; exit 2",
"printf 'never reached'",
]))
result = runner.run_pre_tool_use("Bash", '{}')
assert result.denied
assert result.messages == ["first ok", "deny"]
def test_post_tool_use(self):
runner = HookRunner(HookConfig(post_tool_use=["printf 'post ok'"]))
result = runner.run_post_tool_use("Read", '{}', "file contents", is_error=False)
assert not result.denied
assert result.messages == ["post ok"]
def test_env_vars_passed(self):
runner = HookRunner(HookConfig(pre_tool_use=[
'printf "%s %s" "$HOOK_EVENT" "$HOOK_TOOL_NAME"'
]))
result = runner.run_pre_tool_use("Bash", '{"command":"ls"}')
assert not result.denied
assert result.messages == ["PreToolUse Bash"]
def test_stdin_payload(self):
runner = HookRunner(HookConfig(pre_tool_use=[
"""python3 -c "import sys, json; d=json.load(sys.stdin); print(d['tool_name'])" """
]))
result = runner.run_pre_tool_use("Grep", '{"pattern":"foo"}')
assert not result.denied
assert result.messages == ["Grep"]
def test_multiple_commands_collect_messages(self):
runner = HookRunner(HookConfig(pre_tool_use=[
"printf 'msg1'",
"printf 'msg2'",
]))
result = runner.run_pre_tool_use("Read", '{}')
assert not result.denied
assert result.messages == ["msg1", "msg2"]
def test_from_settings(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump({"hooks": {"pre_tool_use": ["printf 'from settings'"]}}, f)
f.flush()
runner = HookRunner.from_settings(f.name)
result = runner.run_pre_tool_use("Bash", '{}')
assert not result.denied
assert result.messages == ["from settings"]
Path(f.name).unlink()