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/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 diff --git a/src/deepwork/hooks/check_version.sh b/src/deepwork/hooks/check_version.sh index 8ceb96e7..f06d89a0 100755 --- a/src/deepwork/hooks/check_version.sh +++ b/src/deepwork/hooks/check_version.sh @@ -1,14 +1,77 @@ #!/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() { + # 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 +} + +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 rules clear_queue' 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/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]: diff --git a/tests/shell_script_tests/test_check_version.py b/tests/shell_script_tests/test_check_version.py index 2fe3c44d..69fe8808 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,70 @@ 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 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."""