diff --git a/.claude/settings.json b/.claude/settings.json index a0bf4664..4d45bb0d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -94,7 +94,32 @@ "Bash(node:*)", "Bash(npm:*)", "Bash(npx:*)", - "Edit(./**)" + "Edit(./**)", + "Read(./.deepwork/tmp/**)", + "Edit(./.deepwork/tmp/**)", + "Write(./.deepwork/tmp/**)", + "Skill(commit)", + "Skill(commit.review)", + "Skill(commit.test)", + "Skill(commit.lint)", + "Skill(commit.commit_and_push)", + "Skill(deepwork_jobs)", + "Skill(deepwork_jobs.define)", + "Skill(deepwork_jobs.review_job_spec)", + "Skill(deepwork_jobs.implement)", + "Skill(deepwork_jobs.learn)", + "Skill(add_platform)", + "Skill(add_platform.research)", + "Skill(add_platform.add_capabilities)", + "Skill(add_platform.implement)", + "Skill(add_platform.verify)", + "Skill(update)", + "Skill(update.job)", + "Skill(manual_tests)", + "Skill(manual_tests.run_not_fire_tests)", + "Skill(manual_tests.run_fire_tests)", + "Skill(deepwork_rules)", + "Skill(deepwork_rules.define)" ] }, "hooks": { diff --git a/.claude/skills/manual_tests.run_not_fire_tests/SKILL.md b/.claude/skills/manual_tests.run_not_fire_tests/SKILL.md index 3bff6fff..c7f0c74c 100644 --- a/.claude/skills/manual_tests.run_not_fire_tests/SKILL.md +++ b/.claude/skills/manual_tests.run_not_fire_tests/SKILL.md @@ -68,10 +68,6 @@ Run all 8 "should NOT fire" tests in **parallel** sub-agents, then verify no blo Use the Task tool to spawn **ALL of the following sub-agents in a SINGLE message** (parallel execution). Each sub-agent should use a fast model like haiku. - For each test, the sub-agent must: - - Edit BOTH the trigger file AND the safety file - - This satisfies the rule's safety condition, so the rule should NOT fire - **Sub-agent prompts (launch all 8 in parallel):** a. **Trigger/Safety test** - "Edit `manual_tests/test_trigger_safety_mode/feature.py` to add a comment, AND edit `manual_tests/test_trigger_safety_mode/feature_doc.md` to add a note. Both files must be edited so the rule does NOT fire." diff --git a/.gemini/skills/manual_tests/run_not_fire_tests.toml b/.gemini/skills/manual_tests/run_not_fire_tests.toml index 4f206539..b80f0387 100644 --- a/.gemini/skills/manual_tests/run_not_fire_tests.toml +++ b/.gemini/skills/manual_tests/run_not_fire_tests.toml @@ -46,10 +46,6 @@ Run all 8 "should NOT fire" tests in **parallel** sub-agents, then verify no blo Use the Task tool to spawn **ALL of the following sub-agents in a SINGLE message** (parallel execution). Each sub-agent should use a fast model like haiku. - For each test, the sub-agent must: - - Edit BOTH the trigger file AND the safety file - - This satisfies the rule's safety condition, so the rule should NOT fire - **Sub-agent prompts (launch all 8 in parallel):** a. **Trigger/Safety test** - "Edit `manual_tests/test_trigger_safety_mode/feature.py` to add a comment, AND edit `manual_tests/test_trigger_safety_mode/feature_doc.md` to add a note. Both files must be edited so the rule does NOT fire." diff --git a/src/deepwork/cli/sync.py b/src/deepwork/cli/sync.py index de5bbe4f..00588dcd 100644 --- a/src/deepwork/cli/sync.py +++ b/src/deepwork/cli/sync.py @@ -136,6 +136,7 @@ def sync_skills(project_path: Path) -> None: ensure_dir(skills_dir) # Generate skills for all jobs + all_skill_paths: list[Path] = [] if jobs: console.print(" [dim]•[/dim] Generating skills...") for job in jobs: @@ -143,6 +144,7 @@ def sync_skills(project_path: Path) -> None: job_paths = generator.generate_all_skills( job, adapter, platform_dir, project_root=project_path ) + all_skill_paths.extend(job_paths) stats["skills"] += len(job_paths) console.print(f" [green]✓[/green] {job.name} ({len(job_paths)} skills)") except Exception as e: @@ -159,6 +161,28 @@ def sync_skills(project_path: Path) -> None: except Exception as e: console.print(f" [red]✗[/red] Failed to sync hooks: {e}") + # Sync required permissions to platform settings + console.print(" [dim]•[/dim] Syncing permissions...") + try: + perms_count = adapter.sync_permissions(project_path) + if perms_count > 0: + console.print(f" [green]✓[/green] Added {perms_count} base permission(s)") + else: + console.print(" [dim]•[/dim] Base permissions already configured") + except Exception as e: + console.print(f" [red]✗[/red] Failed to sync permissions: {e}") + + # Add skill permissions for generated skills (if adapter supports it) + if all_skill_paths and hasattr(adapter, "add_skill_permissions"): + try: + skill_perms_count = adapter.add_skill_permissions(project_path, all_skill_paths) + if skill_perms_count > 0: + console.print( + f" [green]✓[/green] Added {skill_perms_count} skill permission(s)" + ) + except Exception as e: + console.print(f" [red]✗[/red] Failed to sync skill permissions: {e}") + stats["platforms"] += 1 synced_adapters.append(adapter) diff --git a/src/deepwork/core/adapters.py b/src/deepwork/core/adapters.py index 9c52bde0..cae2948d 100644 --- a/src/deepwork/core/adapters.py +++ b/src/deepwork/core/adapters.py @@ -241,6 +241,25 @@ def sync_hooks(self, project_path: Path, hooks: dict[str, list[dict[str, Any]]]) """ pass + def sync_permissions(self, project_path: Path) -> int: + """ + Sync required permissions to platform settings. + + This method adds any permissions that DeepWork requires to function + properly (e.g., access to .deepwork/tmp/ directory). + + Args: + project_path: Path to project root + + Returns: + Number of permissions added + + Raises: + AdapterError: If sync fails + """ + # Default implementation does nothing - subclasses can override + return 0 + def _hook_already_present(hooks: list[dict[str, Any]], script_path: str) -> bool: """Check if a hook with the given script path is already in the list.""" @@ -344,6 +363,192 @@ def sync_hooks(self, project_path: Path, hooks: dict[str, list[dict[str, Any]]]) total = sum(len(hooks_list) for hooks_list in hooks.values()) return total + def _load_settings(self, project_path: Path) -> dict[str, Any]: + """ + Load settings.json from the project. + + Args: + project_path: Path to project root + + Returns: + Settings dictionary (empty dict if file doesn't exist) + + Raises: + AdapterError: If file exists but cannot be read + """ + settings_file = project_path / self.config_dir / "settings.json" + if settings_file.exists(): + try: + with open(settings_file, encoding="utf-8") as f: + result: dict[str, Any] = json.load(f) + return result + except (json.JSONDecodeError, OSError) as e: + raise AdapterError(f"Failed to read settings.json: {e}") from e + return {} + + def _save_settings(self, project_path: Path, settings: dict[str, Any]) -> None: + """ + Save settings.json to the project. + + Args: + project_path: Path to project root + settings: Settings dictionary to save + + Raises: + AdapterError: If file cannot be written + """ + settings_file = project_path / self.config_dir / "settings.json" + try: + settings_file.parent.mkdir(parents=True, exist_ok=True) + with open(settings_file, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=2) + except OSError as e: + raise AdapterError(f"Failed to write settings.json: {e}") from e + + def add_permission( + self, project_path: Path, permission: str, settings: dict[str, Any] | None = None + ) -> bool: + """ + Add a single permission to settings.json allow list. + + Args: + project_path: Path to project root + permission: The permission string to add (e.g., "Read(./.deepwork/tmp/**)") + settings: Optional pre-loaded settings dict. If provided, modifies in-place + and does NOT save to disk (caller is responsible for saving). + If None, loads settings, adds permission, and saves. + + Returns: + True if permission was added, False if already present + + Raises: + AdapterError: If settings cannot be read/written + """ + save_after = settings is None + if settings is None: + settings = self._load_settings(project_path) + + # Ensure permissions structure exists + if "permissions" not in settings: + settings["permissions"] = {} + if "allow" not in settings["permissions"]: + settings["permissions"]["allow"] = [] + + # Add permission if not already present + allow_list = settings["permissions"]["allow"] + if permission not in allow_list: + allow_list.append(permission) + if save_after: + self._save_settings(project_path, settings) + return True + return False + + 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. + + Args: + project_path: Path to project root + + Returns: + Number of permissions added + + Raises: + AdapterError: If sync fails + """ + # Define required permissions for .deepwork/tmp/** + # Uses ./ prefix for paths relative to project root (per Claude Code docs) + required_permissions = [ + "Read(./.deepwork/tmp/**)", + "Edit(./.deepwork/tmp/**)", + "Write(./.deepwork/tmp/**)", + ] + + # Load settings once, add all permissions, then save once + settings = self._load_settings(project_path) + added_count = 0 + + for permission in required_permissions: + if self.add_permission(project_path, permission, settings): + added_count += 1 + + # Save if any permissions were added + if added_count > 0: + self._save_settings(project_path, settings) + + return added_count + + def add_skill_permissions(self, project_path: Path, skill_paths: list[Path]) -> int: + """ + Add Skill permissions for generated skills to settings.json. + + This allows Claude to invoke the skills without permission prompts. + Uses the Skill(name) permission syntax. + + Note: Skill permissions are an emerging Claude Code feature and + behavior may vary between versions. + + Args: + project_path: Path to project root + skill_paths: List of paths to generated skill files + + Returns: + Number of permissions added + + Raises: + AdapterError: If sync fails + """ + if not skill_paths: + return 0 + + # Load settings once + settings = self._load_settings(project_path) + added_count = 0 + + for skill_path in skill_paths: + # Extract skill name from path + # Path format: .claude/skills/job_name/SKILL.md -> job_name + # Path format: .claude/skills/job_name.step_id/SKILL.md -> job_name.step_id + skill_name = self._extract_skill_name(skill_path) + if skill_name: + permission = f"Skill({skill_name})" + if self.add_permission(project_path, permission, settings): + added_count += 1 + + # Save if any permissions were added + if added_count > 0: + self._save_settings(project_path, settings) + + return added_count + + def _extract_skill_name(self, skill_path: Path) -> str | None: + """ + Extract skill name from a skill file path. + + Args: + skill_path: Path to skill file (e.g., .claude/skills/job_name/SKILL.md) + + Returns: + Skill name (e.g., "job_name") or None if cannot extract + """ + # Handle both absolute and relative paths + parts = skill_path.parts + + # Find 'skills' directory and get the next part + try: + skills_idx = parts.index("skills") + if skills_idx + 1 < len(parts): + # The skill name is the directory after 'skills' + # e.g., skills/job_name/SKILL.md -> job_name + return parts[skills_idx + 1] + except ValueError: + pass + + return None + class GeminiAdapter(AgentAdapter): """Adapter for Gemini CLI. diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py index 4f2f3e17..981dee5f 100644 --- a/tests/unit/test_adapters.py +++ b/tests/unit/test_adapters.py @@ -2,6 +2,7 @@ import json from pathlib import Path +from typing import Any import pytest @@ -186,6 +187,164 @@ def test_sync_hooks_empty_hooks_returns_zero(self, temp_dir: Path) -> None: assert count == 0 + def test_sync_permissions_creates_settings_file(self, temp_dir: Path) -> None: + """Test sync_permissions creates settings.json when it doesn't exist.""" + (temp_dir / ".claude").mkdir() + adapter = ClaudeAdapter(temp_dir) + + count = adapter.sync_permissions(temp_dir) + + assert count == 3 # Read, Edit, Write for .deepwork/tmp/** + 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"] + + def test_sync_permissions_merges_with_existing(self, temp_dir: Path) -> None: + """Test sync_permissions merges with existing settings.""" + claude_dir = temp_dir / ".claude" + claude_dir.mkdir() + settings_file = claude_dir / "settings.json" + settings_file.write_text(json.dumps({"permissions": {"allow": ["Bash(ls:*)"]}})) + + adapter = ClaudeAdapter(temp_dir) + adapter.sync_permissions(temp_dir) + + settings = json.loads(settings_file.read_text()) + assert "Bash(ls:*)" in settings["permissions"]["allow"] + assert "Read(./.deepwork/tmp/**)" in settings["permissions"]["allow"] + + def test_sync_permissions_idempotent(self, temp_dir: Path) -> None: + """Test sync_permissions is idempotent (doesn't duplicate permissions).""" + (temp_dir / ".claude").mkdir() + adapter = ClaudeAdapter(temp_dir) + + # First call adds permissions + count1 = adapter.sync_permissions(temp_dir) + assert count1 == 3 + + # Second call should add nothing + count2 = adapter.sync_permissions(temp_dir) + assert count2 == 0 + + # Verify no duplicates + 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 + + def test_add_permission_single(self, temp_dir: Path) -> None: + """Test add_permission adds a single permission.""" + (temp_dir / ".claude").mkdir() + adapter = ClaudeAdapter(temp_dir) + + result = adapter.add_permission(temp_dir, "Bash(custom:*)") + + assert result is True + settings_file = temp_dir / ".claude" / "settings.json" + settings = json.loads(settings_file.read_text()) + assert "Bash(custom:*)" in settings["permissions"]["allow"] + + def test_add_permission_idempotent(self, temp_dir: Path) -> None: + """Test add_permission doesn't duplicate existing permissions.""" + (temp_dir / ".claude").mkdir() + adapter = ClaudeAdapter(temp_dir) + + # First call adds + result1 = adapter.add_permission(temp_dir, "Bash(custom:*)") + assert result1 is True + + # Second call should return False + result2 = adapter.add_permission(temp_dir, "Bash(custom:*)") + assert result2 is False + + # Verify no duplicates + settings_file = temp_dir / ".claude" / "settings.json" + settings = json.loads(settings_file.read_text()) + assert settings["permissions"]["allow"].count("Bash(custom:*)") == 1 + + def test_add_permission_with_settings_dict(self, temp_dir: Path) -> None: + """Test add_permission with pre-loaded settings (doesn't save).""" + (temp_dir / ".claude").mkdir() + adapter = ClaudeAdapter(temp_dir) + settings: dict[str, Any] = {"permissions": {"allow": []}} + + result = adapter.add_permission(temp_dir, "Bash(test:*)", settings) + + assert result is True + assert "Bash(test:*)" in settings["permissions"]["allow"] + # File should not exist since we passed settings dict + settings_file = temp_dir / ".claude" / "settings.json" + assert not settings_file.exists() + + def test_extract_skill_name_from_path(self, temp_dir: Path) -> None: + """Test _extract_skill_name extracts skill name from skill path.""" + adapter = ClaudeAdapter(temp_dir) + + # Test meta-skill path + path1 = temp_dir / ".claude" / "skills" / "my_job" / "SKILL.md" + assert adapter._extract_skill_name(path1) == "my_job" + + # Test step skill path + path2 = temp_dir / ".claude" / "skills" / "my_job.step_one" / "SKILL.md" + assert adapter._extract_skill_name(path2) == "my_job.step_one" + + def test_extract_skill_name_returns_none_for_invalid_path(self, temp_dir: Path) -> None: + """Test _extract_skill_name returns None for paths without skills dir.""" + adapter = ClaudeAdapter(temp_dir) + + path = temp_dir / ".claude" / "commands" / "my_command.md" + assert adapter._extract_skill_name(path) is None + + def test_add_skill_permissions(self, temp_dir: Path) -> None: + """Test add_skill_permissions adds Skill permissions for each skill.""" + (temp_dir / ".claude").mkdir() + adapter = ClaudeAdapter(temp_dir) + + skill_paths = [ + temp_dir / ".claude" / "skills" / "job_a" / "SKILL.md", + temp_dir / ".claude" / "skills" / "job_a.step_one" / "SKILL.md", + temp_dir / ".claude" / "skills" / "job_b" / "SKILL.md", + ] + + count = adapter.add_skill_permissions(temp_dir, skill_paths) + + assert count == 3 + settings_file = temp_dir / ".claude" / "settings.json" + settings = json.loads(settings_file.read_text()) + assert "Skill(job_a)" in settings["permissions"]["allow"] + assert "Skill(job_a.step_one)" in settings["permissions"]["allow"] + assert "Skill(job_b)" in settings["permissions"]["allow"] + + def test_add_skill_permissions_idempotent(self, temp_dir: Path) -> None: + """Test add_skill_permissions doesn't duplicate permissions.""" + (temp_dir / ".claude").mkdir() + adapter = ClaudeAdapter(temp_dir) + + skill_paths = [temp_dir / ".claude" / "skills" / "my_job" / "SKILL.md"] + + # First call adds + count1 = adapter.add_skill_permissions(temp_dir, skill_paths) + assert count1 == 1 + + # Second call should add nothing + count2 = adapter.add_skill_permissions(temp_dir, skill_paths) + assert count2 == 0 + + def test_add_skill_permissions_empty_list(self, temp_dir: Path) -> None: + """Test add_skill_permissions with empty list returns 0.""" + adapter = ClaudeAdapter(temp_dir) + + count = adapter.add_skill_permissions(temp_dir, []) + + assert count == 0 + class TestGeminiAdapter: """Tests for GeminiAdapter."""