Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@
]
},
"hooks": {
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "src/deepwork/hooks/check_version.sh"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
Expand Down
167 changes: 167 additions & 0 deletions src/deepwork/hooks/check_version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/bin/bash
# check_version.sh - SessionStart hook to check Claude Code version
#
# Warns users if their Claude Code version is below the minimum required
# version, as older versions may have bugs that affect DeepWork functionality.
#
# Uses hookSpecificOutput.additionalContext to pass the warning to Claude's
# context so it can inform the user appropriately.

# ============================================================================
# RE-ENTRY GUARD
# ============================================================================
# SessionStart hooks can be triggered multiple times in a session (on resume,
# clear, etc.). We only want to show the version warning once per session to
# avoid spamming the user. We use an environment variable to track whether
# we've already run. Note: This relies on the parent process preserving env
# vars across hook invocations within the same session.
if [ -n "$DEEPWORK_VERSION_CHECK_DONE" ]; then
# Already checked version this session, exit silently with empty JSON
echo '{}'
exit 0
fi
export DEEPWORK_VERSION_CHECK_DONE=1

# ============================================================================
# MINIMUM VERSION CONFIGURATION
# ============================================================================
MINIMUM_VERSION="2.1.14"

# ============================================================================
# VERSION CHECK LOGIC
# ============================================================================

# Get current Claude Code version
get_current_version() {
local version_output
version_output=$(claude --version 2>/dev/null) || return 1
# Extract version number (e.g., "2.1.1" from "2.1.1 (Claude Code)")
echo "$version_output" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+' | head -1
}

# Compare two semantic versions
# Returns 0 if version1 >= version2, 1 otherwise
version_gte() {
local version1="$1"
local version2="$2"

# Split versions into components
local v1_major v1_minor v1_patch
local v2_major v2_minor v2_patch

IFS='.' read -r v1_major v1_minor v1_patch <<< "$version1"
IFS='.' read -r v2_major v2_minor v2_patch <<< "$version2"

# Default to 0 if component is missing
v1_major=${v1_major:-0}
v1_minor=${v1_minor:-0}
v1_patch=${v1_patch:-0}
v2_major=${v2_major:-0}
v2_minor=${v2_minor:-0}
v2_patch=${v2_patch:-0}

# Compare major version
if [ "$v1_major" -gt "$v2_major" ]; then
return 0
elif [ "$v1_major" -lt "$v2_major" ]; then
return 1
fi

# Compare minor version
if [ "$v1_minor" -gt "$v2_minor" ]; then
return 0
elif [ "$v1_minor" -lt "$v2_minor" ]; then
return 1
fi

# Compare patch version
if [ "$v1_patch" -ge "$v2_patch" ]; then
return 0
else
return 1
fi
}

# Generate warning message
get_warning_message() {
local current_version="$1"

cat << EOF
CLAUDE CODE VERSION WARNING: Your version (${current_version}) is below the minimum recommended (${MINIMUM_VERSION}). Older versions have known bugs that may cause issues with DeepWork. RECOMMENDED: Update your version of Claude Code. TELL THE USER ABOUT THIS ISSUE LOUDLY!!!
EOF
}

# Print visual warning to stderr for immediate user visibility
print_stderr_warning() {
local current_version="$1"

cat >&2 << EOF

================================================================================
*** CLAUDE CODE VERSION WARNING ***
================================================================================

Your Claude Code version: ${current_version}
Minimum recommended: ${MINIMUM_VERSION}

IMPORTANT: Versions below the minimum have known bugs that may cause
issues with DeepWork functionality. You may experience unexpected
behavior, errors, or incomplete operations.

------------------------------------------------------------------------
| |
| RECOMMENDED ACTION: Update your version of Claude Code |
| |
------------------------------------------------------------------------

================================================================================

EOF
}

# Output JSON with additional context for Claude
output_json_with_context() {
local context="$1"
# Escape special characters for JSON
local escaped_context
escaped_context=$(echo "$context" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr '\n' ' ')

cat << EOF
{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"${escaped_context}"}}
EOF
}

# ============================================================================
# MAIN
# ============================================================================

main() {
local current_version
local warning_message

# Get current version (don't exit on failure)
current_version=$(get_current_version) || current_version=""

if [ -z "$current_version" ]; then
# Could not determine version, output empty JSON and exit
echo '{}'
exit 0
fi

# Check if current version is below minimum
if ! version_gte "$current_version" "$MINIMUM_VERSION"; then
# Print visual warning to stderr
print_stderr_warning "$current_version"

# Output JSON with context for Claude
warning_message=$(get_warning_message "$current_version")
output_json_with_context "$warning_message"
else
# Version is OK, output empty JSON
echo '{}'
fi

exit 0
}

main "$@"
211 changes: 211 additions & 0 deletions tests/shell_script_tests/test_check_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
"""Tests for check_version.sh SessionStart hook.

Tests version checking logic, JSON output format, and warning behavior.
"""

import os
import subprocess
import tempfile
from pathlib import Path

import pytest


@pytest.fixture
def check_version_script(hooks_dir: Path) -> Path:
"""Return path to check_version.sh."""
return hooks_dir / "check_version.sh"


def run_check_version_with_mock_claude(
script_path: Path,
mock_version: str | None,
cwd: Path | None = None,
) -> tuple[str, str, int]:
"""
Run check_version.sh with a mocked claude command.

Args:
script_path: Path to check_version.sh
mock_version: Version string to return from mock claude, or None for failure
cwd: Working directory

Returns:
Tuple of (stdout, stderr, return_code)
"""
with tempfile.TemporaryDirectory() as tmpdir:
# Create mock claude command
mock_claude = Path(tmpdir) / "claude"
if mock_version is not None:
mock_claude.write_text(f'#!/bin/bash\necho "{mock_version} (Claude Code)"\n')
else:
mock_claude.write_text("#!/bin/bash\nexit 1\n")
mock_claude.chmod(0o755)

# Prepend mock dir to PATH
env = os.environ.copy()
env["PATH"] = f"{tmpdir}:{env.get('PATH', '')}"

result = subprocess.run(
["bash", str(script_path)],
capture_output=True,
text=True,
cwd=cwd or tmpdir,
env=env,
)

return result.stdout, result.stderr, result.returncode


class TestVersionComparison:
"""Tests for version comparison logic."""

def test_equal_versions(self, check_version_script: Path) -> None:
"""Test that equal versions don't trigger warning."""
# Mock version equals minimum (2.1.14)
stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "2.1.14")

assert code == 0
assert "WARNING" not in stderr

def test_greater_patch_version(self, check_version_script: Path) -> None:
"""Test that greater patch version doesn't trigger warning."""
stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "2.1.15")

assert code == 0
assert "WARNING" not in stderr

def test_greater_minor_version(self, check_version_script: Path) -> None:
"""Test that greater minor version doesn't trigger warning."""
stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "2.2.0")

assert code == 0
assert "WARNING" not in stderr

def test_greater_major_version(self, check_version_script: Path) -> None:
"""Test that greater major version doesn't trigger warning."""
stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "3.0.0")

assert code == 0
assert "WARNING" not in stderr

def test_lesser_patch_version(self, check_version_script: Path) -> None:
"""Test that lesser patch version triggers warning."""
stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "2.1.13")

assert code == 0
assert "WARNING" in stderr
assert "2.1.13" in stderr # Shows current version

def test_lesser_minor_version(self, check_version_script: Path) -> None:
"""Test that lesser minor version triggers warning."""
stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "2.0.99")

assert code == 0
assert "WARNING" in stderr

def test_lesser_major_version(self, check_version_script: Path) -> None:
"""Test that lesser major version triggers warning."""
stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "1.9.99")

assert code == 0
assert "WARNING" in stderr


class TestWarningOutput:
"""Tests for warning message content."""

def test_warning_contains_current_version(self, check_version_script: Path) -> None:
"""Test that warning shows the current version."""
stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "2.0.0")

assert "2.0.0" in stderr

def test_warning_contains_minimum_version(self, check_version_script: Path) -> None:
"""Test that warning shows the minimum version."""
stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "2.0.0")

assert "2.1.14" in stderr

def test_warning_suggests_update(self, check_version_script: Path) -> None:
"""Test that warning suggests updating Claude Code."""
stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "2.0.0")

assert "Update your version of Claude Code" in stderr

def test_warning_mentions_bugs(self, check_version_script: Path) -> None:
"""Test that warning mentions bugs in older versions."""
stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "2.0.0")

assert "bugs" in stderr.lower()


class TestHookConformance:
"""Tests for Claude Code hook format compliance."""

def test_always_exits_zero(self, check_version_script: Path) -> None:
"""Test that script always exits 0 (informational only)."""
# Test with warning
stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "2.0.0")
assert code == 0

# Test without warning
stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "3.0.0")
assert code == 0

def test_outputs_valid_json_when_version_ok(self, check_version_script: Path) -> None:
"""Test that stdout is valid JSON when version is OK."""
import json

stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "3.0.0")

# Should output empty JSON object
output = json.loads(stdout.strip())
assert output == {}

def test_outputs_structured_json_when_version_low(self, check_version_script: Path) -> None:
"""Test that stdout has hookSpecificOutput when version is low."""
import json

stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "2.0.0")

output = json.loads(stdout.strip())
assert "hookSpecificOutput" in output
assert output["hookSpecificOutput"]["hookEventName"] == "SessionStart"
assert "additionalContext" in output["hookSpecificOutput"]
assert "VERSION WARNING" in output["hookSpecificOutput"]["additionalContext"]

def test_warning_goes_to_stderr_and_stdout(self, check_version_script: Path) -> None:
"""Test that warning is on stderr (visual) and stdout (context)."""
stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "2.0.0")

# Visual warning should be in stderr
assert "WARNING" in stderr
# JSON with context should be in stdout
assert "hookSpecificOutput" in stdout


class TestEdgeCases:
"""Tests for edge cases and error handling."""

def test_claude_command_not_found(self, check_version_script: Path) -> None:
"""Test graceful handling when claude command fails."""
stdout, stderr, code = run_check_version_with_mock_claude(
check_version_script,
None, # Mock failure
)

# Should exit 0 and output JSON even if version check fails
assert code == 0
assert stdout.strip() == "{}"
# No warning since we couldn't determine version
assert "WARNING" not in stderr

def test_version_with_extra_text(self, check_version_script: Path) -> None:
"""Test parsing version from output with extra text."""
# Real output format: "2.1.1 (Claude Code)"
stdout, stderr, code = run_check_version_with_mock_claude(check_version_script, "2.1.14")

assert code == 0
# Version 2.1.14 equals minimum, no warning
assert "WARNING" not in stderr