From e687a88c2766817b881c380913ba8e7ed995787c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Jan 2026 17:09:53 +0000 Subject: [PATCH 1/6] feat: Add deepwork installation check to SessionStart hook Check that the 'deepwork' command is installed and directly invokable before proceeding with a session. If deepwork is not available, display a blocking error (exit code 2) with clear instructions: - Error message explains that deepwork must be directly invokable - Warns against using wrappers like 'uv run deepwork' - Provides installation options (pipx, pip install --user, nix develop) This check runs before the Claude Code version check since hooks cannot function without deepwork being available. https://claude.ai/code/session_01LaPvwtJqYWx4yQArYpkCnH --- src/deepwork/hooks/check_version.sh | 73 ++++++++++++++-- .../shell_script_tests/test_check_version.py | 83 +++++++++++++++++++ 2 files changed, 150 insertions(+), 6 deletions(-) diff --git a/src/deepwork/hooks/check_version.sh b/src/deepwork/hooks/check_version.sh index 8ceb96e7..52836044 100755 --- a/src/deepwork/hooks/check_version.sh +++ b/src/deepwork/hooks/check_version.sh @@ -1,14 +1,75 @@ #!/bin/bash -# check_version.sh - SessionStart hook to check Claude Code version +# check_version.sh - SessionStart hook to check Claude Code version and deepwork installation # -# Warns users if their Claude Code version is below the minimum required -# version, as older versions may have bugs that affect DeepWork functionality. +# This hook performs two critical checks: +# 1. Verifies that the 'deepwork' command is installed and directly invokable +# 2. Warns users if their Claude Code version is below the minimum required # -# Uses hookSpecificOutput.additionalContext to pass the warning to Claude's -# context so it can inform the user appropriately. +# The deepwork check is blocking (exit 2) because hooks cannot function without it. +# The version check is informational only (exit 0) to avoid blocking sessions. +# +# Uses hookSpecificOutput.additionalContext to pass messages to Claude's context. + +# ============================================================================ +# DEEPWORK INSTALLATION CHECK (BLOCKING) +# ============================================================================ +# This check runs on EVERY hook invocation (no re-entry guard) because if +# deepwork is not installed, nothing else will work. + +check_deepwork_installed() { + # Try to run deepwork --version + if ! deepwork --version >/dev/null 2>&1; then + return 1 + fi + return 0 +} + +print_deepwork_error() { + cat >&2 << 'EOF' + +================================================================================ + *** DEEPWORK NOT INSTALLED *** +================================================================================ + + ERROR: The 'deepwork' command is not available or cannot be directly invoked. + + DeepWork must be installed such that running 'deepwork' directly works. + For example, running 'deepwork --version' should succeed. + + IMPORTANT: Do NOT use 'uv run deepwork' or similar wrappers. + The command must be directly invokable as just 'deepwork'. + + ------------------------------------------------------------------------ + | | + | Please fix your deepwork installation before proceeding. | + | | + | Installation options: | + | - pipx install deepwork | + | - pip install --user deepwork (ensure ~/.local/bin is in PATH) | + | - nix develop (if using the nix flake) | + | | + ------------------------------------------------------------------------ + +================================================================================ + +EOF +} + +output_deepwork_error_json() { + cat << 'EOF' +{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"DEEPWORK INSTALLATION ERROR: The 'deepwork' command is not installed or cannot be directly invoked. DeepWork must be installed such that it can be directly invoked (e.g., 'deepwork', NOT 'uv run deepwork'). Please fix your deepwork installation before proceeding with anything else. DO NOT CONTINUE until this is resolved."},"error":"deepwork command not found - please install deepwork so it can be directly invoked"} +EOF +} + +# Check deepwork installation FIRST (before any other checks) +if ! check_deepwork_installed; then + print_deepwork_error + output_deepwork_error_json + exit 2 # Blocking error - prevent session from continuing +fi # ============================================================================ -# RE-ENTRY GUARD +# RE-ENTRY GUARD (for version check only) # ============================================================================ # 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 diff --git a/tests/shell_script_tests/test_check_version.py b/tests/shell_script_tests/test_check_version.py index 2fe3c44d..772795e3 100644 --- a/tests/shell_script_tests/test_check_version.py +++ b/tests/shell_script_tests/test_check_version.py @@ -21,6 +21,7 @@ def run_check_version_with_mock_claude( script_path: Path, mock_version: str | None, cwd: Path | None = None, + mock_deepwork: bool = True, ) -> tuple[str, str, int]: """ Run check_version.sh with a mocked claude command. @@ -29,6 +30,8 @@ def run_check_version_with_mock_claude( script_path: Path to check_version.sh mock_version: Version string to return from mock claude, or None for failure cwd: Working directory + mock_deepwork: If True, create a mock deepwork command that succeeds. + If False, do not create mock deepwork (simulates not installed). Returns: Tuple of (stdout, stderr, return_code) @@ -42,6 +45,17 @@ def run_check_version_with_mock_claude( mock_claude.write_text("#!/bin/bash\nexit 1\n") mock_claude.chmod(0o755) + # Create mock deepwork command + # When mock_deepwork=True, create a working mock + # When mock_deepwork=False, create a failing mock that shadows the real one + mock_deepwork_cmd = Path(tmpdir) / "deepwork" + if mock_deepwork: + mock_deepwork_cmd.write_text('#!/bin/bash\necho "deepwork 0.1.0"\n') + else: + # Create a mock that fails (simulating deepwork not being installed) + mock_deepwork_cmd.write_text("#!/bin/bash\nexit 127\n") + mock_deepwork_cmd.chmod(0o755) + # Prepend mock dir to PATH env = os.environ.copy() env["PATH"] = f"{tmpdir}:{env.get('PATH', '')}" @@ -209,3 +223,72 @@ def test_version_with_extra_text(self, check_version_script: Path) -> None: assert code == 0 # Version 2.1.14 equals minimum, no warning assert "WARNING" not in stderr + + +class TestDeepworkInstallationCheck: + """Tests for deepwork installation check (blocking).""" + + def test_deepwork_installed_allows_session(self, check_version_script: Path) -> None: + """Test that script proceeds when deepwork is installed.""" + # With mock_deepwork=True (default), deepwork is available + stdout, stderr, code = run_check_version_with_mock_claude( + check_version_script, "3.0.0", mock_deepwork=True + ) + + assert code == 0 + assert "DEEPWORK NOT INSTALLED" not in stderr + + def test_deepwork_not_installed_blocks_session(self, check_version_script: Path) -> None: + """Test that script blocks when deepwork is not installed.""" + stdout, stderr, code = run_check_version_with_mock_claude( + check_version_script, "3.0.0", mock_deepwork=False + ) + + # Should exit with code 2 (blocking error) + assert code == 2 + assert "DEEPWORK NOT INSTALLED" in stderr + + def test_deepwork_error_message_content(self, check_version_script: Path) -> None: + """Test that deepwork error message has helpful content.""" + stdout, stderr, code = run_check_version_with_mock_claude( + check_version_script, "3.0.0", mock_deepwork=False + ) + + # Should mention direct invocation requirement + assert "directly invok" in stderr.lower() + # Should mention NOT using wrappers + assert "uv run deepwork" in stderr + # Should suggest installation options + assert "pipx" in stderr or "pip install" in stderr + + def test_deepwork_error_outputs_json(self, check_version_script: Path) -> None: + """Test that deepwork error outputs valid JSON with error info.""" + import json + + stdout, stderr, code = run_check_version_with_mock_claude( + check_version_script, "3.0.0", mock_deepwork=False + ) + + output = json.loads(stdout.strip()) + assert "hookSpecificOutput" in output + assert "error" in output + assert "deepwork" in output["error"].lower() + # Should have additional context for Claude + assert "additionalContext" in output["hookSpecificOutput"] + assert "DEEPWORK" in output["hookSpecificOutput"]["additionalContext"] + + def test_deepwork_check_happens_before_version_check( + self, check_version_script: Path + ) -> None: + """Test that deepwork check runs before version check.""" + # Even with a low version that would trigger warning, + # missing deepwork should block first + stdout, stderr, code = run_check_version_with_mock_claude( + check_version_script, "1.0.0", mock_deepwork=False + ) + + # Should exit with deepwork error, not version warning + assert code == 2 + assert "DEEPWORK NOT INSTALLED" in stderr + # Should NOT show version warning + assert "CLAUDE CODE VERSION WARNING" not in stderr From 5675b8cda29bf0784e63ac222ced0dd04c73a77e Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Fri, 23 Jan 2026 10:26:13 -0700 Subject: [PATCH 2/6] refactor: Use `deepwork rules clear_queue` for installation check (#133) Change the deepwork installation check from `deepwork --version` to `deepwork rules clear_queue`. This serves double utility: 1. Verifies the deepwork command is installed and directly invokable 2. Clears any stale rules from the queue, ensuring a clean session start https://claude.ai/code/session_014UavMC2aJxUh1uwhhJqz7S Co-authored-by: Claude --- src/deepwork/hooks/check_version.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/deepwork/hooks/check_version.sh b/src/deepwork/hooks/check_version.sh index 52836044..f06d89a0 100755 --- a/src/deepwork/hooks/check_version.sh +++ b/src/deepwork/hooks/check_version.sh @@ -17,8 +17,10 @@ # deepwork is not installed, nothing else will work. check_deepwork_installed() { - # Try to run deepwork --version - if ! deepwork --version >/dev/null 2>&1; then + # Run 'deepwork rules clear_queue' instead of just '--version' for double utility: + # 1. Verifies that the 'deepwork' command is installed and directly invokable + # 2. Clears any stale rules from the queue, ensuring a clean slate for the session + if ! deepwork rules clear_queue >/dev/null 2>&1; then return 1 fi return 0 @@ -34,7 +36,7 @@ print_deepwork_error() { ERROR: The 'deepwork' command is not available or cannot be directly invoked. DeepWork must be installed such that running 'deepwork' directly works. - For example, running 'deepwork --version' should succeed. + For example, running 'deepwork rules clear_queue' should succeed. IMPORTANT: Do NOT use 'uv run deepwork' or similar wrappers. The command must be directly invokable as just 'deepwork'. From 7a25ab35c4b0e4c609e7dd90a8983562c3a80c70 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Jan 2026 17:29:57 +0000 Subject: [PATCH 3/6] style: Apply ruff format to test_check_version.py https://claude.ai/code/session_01LaPvwtJqYWx4yQArYpkCnH --- tests/shell_script_tests/test_check_version.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/shell_script_tests/test_check_version.py b/tests/shell_script_tests/test_check_version.py index 772795e3..69fe8808 100644 --- a/tests/shell_script_tests/test_check_version.py +++ b/tests/shell_script_tests/test_check_version.py @@ -277,9 +277,7 @@ def test_deepwork_error_outputs_json(self, check_version_script: Path) -> None: assert "additionalContext" in output["hookSpecificOutput"] assert "DEEPWORK" in output["hookSpecificOutput"]["additionalContext"] - def test_deepwork_check_happens_before_version_check( - self, check_version_script: Path - ) -> None: + def test_deepwork_check_happens_before_version_check(self, check_version_script: Path) -> None: """Test that deepwork check runs before version check.""" # Even with a low version that would trigger warning, # missing deepwork should block first From 1fa7e47a17a20e9645cf53bf2e71376ea8537072 Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Fri, 23 Jan 2026 10:36:23 -0700 Subject: [PATCH 4/6] Add permissions for jobs directory and deepwork CLI commands (#134) * feat: Add deepwork CLI and jobs folder permissions to installer Add required permissions to Claude Code settings.json during sync: - Bash(deepwork:*) for all deepwork CLI commands - Read/Edit/Write for .deepwork/jobs/** for job definitions https://claude.ai/code/session_01NJKQitsNTE3HUcPuDBM7iM * refactor: Simplify permissions to full .deepwork/** access Instead of separate permissions for tmp and jobs subdirectories, grant full Read/Edit/Write access to the entire .deepwork directory. https://claude.ai/code/session_01NJKQitsNTE3HUcPuDBM7iM --------- Co-authored-by: Claude --- src/deepwork/core/adapters.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/deepwork/core/adapters.py b/src/deepwork/core/adapters.py index d372780c..a6354770 100644 --- a/src/deepwork/core/adapters.py +++ b/src/deepwork/core/adapters.py @@ -447,8 +447,9 @@ def sync_permissions(self, project_path: Path) -> int: """ Sync required permissions to Claude Code settings.json. - Adds permissions for unrestricted access to .deepwork/tmp/** directory, - which is used for temporary files during DeepWork operations. + Adds permissions for: + - .deepwork/** - full access to deepwork directory + - All deepwork CLI commands (deepwork:*) Args: project_path: Path to project root @@ -459,13 +460,15 @@ def sync_permissions(self, project_path: Path) -> int: Raises: AdapterError: If sync fails """ - # Define required permissions for .deepwork/tmp/** + # Define required permissions for DeepWork functionality # Uses ./ prefix for paths relative to project root (per Claude Code docs) required_permissions = [ - "Read(./.deepwork/tmp/**)", - "Edit(./.deepwork/tmp/**)", - "Write(./.deepwork/tmp/**)", - "Bash(rm -rf .deepwork/tmp/rules/queue/*.json)", + # Full access to .deepwork directory + "Read(./.deepwork/**)", + "Edit(./.deepwork/**)", + "Write(./.deepwork/**)", + # All deepwork CLI commands + "Bash(deepwork:*)", ] # Load settings once, add all permissions, then save once From b1f5cf7a75cfb128d9e3337c5d20b7c48aa2f25c Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Fri, 23 Jan 2026 10:37:51 -0700 Subject: [PATCH 5/6] fix: Reset file permissions after copying during install (#135) When deepwork is installed with restrictive permissions (e.g., read-only), shutil.copytree/copy preserve those permissions on copied files. This causes issues when users later try to modify or delete the copied files. Added fix_permissions() utility that ensures user-writable permissions (u+rw for files, u+rwx for directories) while preserving executable bits. Applied to all file copy operations in install.py and fs.py copy_dir(). https://claude.ai/code/session_01QpLzkbcggStMKFCmTcE4Bs Co-authored-by: Claude --- src/deepwork/cli/install.py | 8 +++++++- src/deepwork/utils/fs.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/deepwork/cli/install.py b/src/deepwork/cli/install.py index 5d525475..843445fc 100644 --- a/src/deepwork/cli/install.py +++ b/src/deepwork/cli/install.py @@ -8,7 +8,7 @@ from deepwork.core.adapters import AgentAdapter from deepwork.core.detector import PlatformDetector -from deepwork.utils.fs import ensure_dir +from deepwork.utils.fs import ensure_dir, fix_permissions from deepwork.utils.git import is_git_repo from deepwork.utils.yaml_utils import load_yaml, save_yaml @@ -52,6 +52,8 @@ def _inject_standard_job(job_name: str, jobs_dir: Path, project_path: Path) -> N shutil.rmtree(target_dir) shutil.copytree(standard_jobs_dir, target_dir) + # Fix permissions - source may have restrictive permissions (e.g., read-only) + fix_permissions(target_dir) console.print( f" [green]✓[/green] Installed {job_name} ({target_dir.relative_to(project_path)})" ) @@ -63,6 +65,8 @@ def _inject_standard_job(job_name: str, jobs_dir: Path, project_path: Path) -> N for doc_spec_file in doc_specs_source.glob("*.md"): target_doc_spec = doc_specs_target / doc_spec_file.name shutil.copy(doc_spec_file, target_doc_spec) + # Fix permissions for copied doc spec + fix_permissions(target_doc_spec) console.print( f" [green]✓[/green] Installed doc spec {doc_spec_file.name} ({target_doc_spec.relative_to(project_path)})" ) @@ -174,6 +178,8 @@ def _create_rules_directory(project_path: Path) -> bool: for example_file in example_rules_dir.glob("*.md.example"): dest_file = rules_dir / example_file.name shutil.copy(example_file, dest_file) + # Fix permissions for copied rule template + fix_permissions(dest_file) # Create a README file explaining the rules system readme_content = """# DeepWork Rules diff --git a/src/deepwork/utils/fs.py b/src/deepwork/utils/fs.py index f650516a..8230cb1b 100644 --- a/src/deepwork/utils/fs.py +++ b/src/deepwork/utils/fs.py @@ -1,9 +1,43 @@ """Filesystem utilities for safe file operations.""" import shutil +import stat from pathlib import Path +def fix_permissions(path: Path | str) -> None: + """ + Fix file permissions after copying to ensure files are user-writable. + + This is needed because shutil.copytree/copy preserve source permissions, + and if the source was installed with restrictive permissions (e.g., read-only), + the copied files would also be read-only. + + For directories: Sets rwx for user (0o700 minimum) + For files: Sets rw for user (0o600 minimum), preserves executable bit + + Args: + path: File or directory path to fix permissions for + """ + path_obj = Path(path) + + if path_obj.is_file(): + # Get current permissions + current_mode = path_obj.stat().st_mode + # Ensure user can read and write, preserve executable bit + new_mode = current_mode | stat.S_IRUSR | stat.S_IWUSR + path_obj.chmod(new_mode) + elif path_obj.is_dir(): + # Fix directory permissions first (need execute to traverse) + current_mode = path_obj.stat().st_mode + new_mode = current_mode | stat.S_IRWXU # rwx for user + path_obj.chmod(new_mode) + + # Recursively fix all contents + for item in path_obj.iterdir(): + fix_permissions(item) + + def ensure_dir(path: Path | str) -> Path: """ Create directory if it doesn't exist. @@ -94,6 +128,8 @@ def _ignore(directory: str, contents: list[str]) -> set[str]: ignore_func = _ignore shutil.copytree(src_path, dst_path, ignore=ignore_func, dirs_exist_ok=True) + # Fix permissions - source may have restrictive permissions (e.g., read-only) + fix_permissions(dst_path) def find_files(directory: Path | str, pattern: str) -> list[Path]: From 4917f772898ee936cc9c1bbbade3aec66541f3ef Mon Sep 17 00:00:00 2001 From: Noah Horton Date: Fri, 23 Jan 2026 10:46:11 -0700 Subject: [PATCH 6/6] fix: Update test assertions to match sync_permissions implementation The sync_permissions tests were checking for old permission patterns (.deepwork/tmp/**) but the implementation was changed to use broader patterns (.deepwork/**) and deepwork CLI permissions. Co-Authored-By: Claude Opus 4.5 --- tests/unit/test_adapters.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py index 88f792e3..36d00287 100644 --- a/tests/unit/test_adapters.py +++ b/tests/unit/test_adapters.py @@ -194,16 +194,16 @@ def test_sync_permissions_creates_settings_file(self, temp_dir: Path) -> None: count = adapter.sync_permissions(temp_dir) - assert count == 4 # Read, Edit, Write for .deepwork/tmp/** + Bash for queue + assert count == 4 # Read, Edit, Write for .deepwork/** + Bash for deepwork CLI settings_file = temp_dir / ".claude" / "settings.json" assert settings_file.exists() settings = json.loads(settings_file.read_text()) assert "permissions" in settings assert "allow" in settings["permissions"] - assert "Read(./.deepwork/tmp/**)" in settings["permissions"]["allow"] - assert "Edit(./.deepwork/tmp/**)" in settings["permissions"]["allow"] - assert "Write(./.deepwork/tmp/**)" in settings["permissions"]["allow"] - assert "Bash(rm -rf .deepwork/tmp/rules/queue/*.json)" in settings["permissions"]["allow"] + assert "Read(./.deepwork/**)" in settings["permissions"]["allow"] + assert "Edit(./.deepwork/**)" in settings["permissions"]["allow"] + assert "Write(./.deepwork/**)" in settings["permissions"]["allow"] + assert "Bash(deepwork:*)" in settings["permissions"]["allow"] def test_sync_permissions_merges_with_existing(self, temp_dir: Path) -> None: """Test sync_permissions merges with existing settings.""" @@ -217,7 +217,7 @@ def test_sync_permissions_merges_with_existing(self, temp_dir: Path) -> None: settings = json.loads(settings_file.read_text()) assert "Bash(ls:*)" in settings["permissions"]["allow"] - assert "Read(./.deepwork/tmp/**)" in settings["permissions"]["allow"] + assert "Read(./.deepwork/**)" in settings["permissions"]["allow"] def test_sync_permissions_idempotent(self, temp_dir: Path) -> None: """Test sync_permissions is idempotent (doesn't duplicate permissions).""" @@ -236,10 +236,10 @@ def test_sync_permissions_idempotent(self, temp_dir: Path) -> None: settings_file = temp_dir / ".claude" / "settings.json" settings = json.loads(settings_file.read_text()) allow_list = settings["permissions"]["allow"] - assert allow_list.count("Read(./.deepwork/tmp/**)") == 1 - assert allow_list.count("Edit(./.deepwork/tmp/**)") == 1 - assert allow_list.count("Write(./.deepwork/tmp/**)") == 1 - assert allow_list.count("Bash(rm -rf .deepwork/tmp/rules/queue/*.json)") == 1 + assert allow_list.count("Read(./.deepwork/**)") == 1 + assert allow_list.count("Edit(./.deepwork/**)") == 1 + assert allow_list.count("Write(./.deepwork/**)") == 1 + assert allow_list.count("Bash(deepwork:*)") == 1 def test_add_permission_single(self, temp_dir: Path) -> None: """Test add_permission adds a single permission."""