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
8 changes: 7 additions & 1 deletion src/deepwork/cli/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)})"
)
Expand All @@ -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)})"
)
Expand Down Expand Up @@ -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
Expand Down
17 changes: 10 additions & 7 deletions src/deepwork/core/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
75 changes: 69 additions & 6 deletions src/deepwork/hooks/check_version.sh
Original file line number Diff line number Diff line change
@@ -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
Expand Down
36 changes: 36 additions & 0 deletions src/deepwork/utils/fs.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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]:
Expand Down
81 changes: 81 additions & 0 deletions tests/shell_script_tests/test_check_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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', '')}"
Expand Down Expand Up @@ -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
20 changes: 10 additions & 10 deletions tests/unit/test_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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)."""
Expand All @@ -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."""
Expand Down