diff --git a/.claude/settings.json b/.claude/settings.json index 6da3b2da..64ff7839 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -94,7 +94,29 @@ "Bash(node:*)", "Bash(npm:*)", "Bash(npx:*)", - "Edit(./**)" + "Edit(./**)", + "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(deepwork_rules:*)", + "Skill(deepwork_rules.define:*)", + "Skill(commit:*)", + "Skill(commit.review:*)", + "Skill(commit.test:*)", + "Skill(commit.lint:*)", + "Skill(commit.commit_and_push:*)", + "Skill(manual_tests:*)", + "Skill(manual_tests.run_not_fire_tests:*)", + "Skill(manual_tests.run_fire_tests:*)", + "Skill(deepwork_jobs:*)", + "Skill(deepwork_jobs.define:*)", + "Skill(deepwork_jobs.review_job_spec:*)", + "Skill(deepwork_jobs.implement:*)", + "Skill(deepwork_jobs.learn:*)" ] }, "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/CHANGELOG.md b/CHANGELOG.md index 60406257..69a8e5d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to DeepWork will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Auto-approval of skill permissions for Claude Code + - Skills generated by DeepWork are automatically added to `.claude/settings.json` permissions + - Uses `Skill(skill_name:*)` wildcard pattern for flexible approval + - Preserves existing permissions and avoids duplicates + - Works with both `deepwork install` and `deepwork sync` commands + - No more permission prompts when invoking generated skills in Claude Code + ## [0.5.0] - 2026-01-20 ### Changed diff --git a/src/deepwork/cli/sync.py b/src/deepwork/cli/sync.py index de5bbe4f..6074441e 100644 --- a/src/deepwork/cli/sync.py +++ b/src/deepwork/cli/sync.py @@ -116,7 +116,7 @@ def sync_skills(project_path: Path) -> None: # Sync each platform generator = SkillGenerator() - stats = {"platforms": 0, "skills": 0, "hooks": 0} + stats = {"platforms": 0, "skills": 0, "hooks": 0, "permissions": 0} synced_adapters: list[AgentAdapter] = [] for platform_name in platforms: @@ -135,6 +135,9 @@ def sync_skills(project_path: Path) -> None: # Create skills directory ensure_dir(skills_dir) + # Collect skill names for permission syncing + skill_names: list[str] = [] + # Generate skills for all jobs if jobs: console.print(" [dim]•[/dim] Generating skills...") @@ -145,6 +148,13 @@ def sync_skills(project_path: Path) -> None: ) stats["skills"] += len(job_paths) console.print(f" [green]✓[/green] {job.name} ({len(job_paths)} skills)") + + # Collect skill names from generated paths + # Meta-skill: job_name + skill_names.append(job.name) + # Step skills: job_name.step_id + for step in job.steps: + skill_names.append(f"{job.name}.{step.id}") except Exception as e: console.print(f" [red]✗[/red] Failed for {job.name}: {e}") @@ -159,6 +169,17 @@ def sync_skills(project_path: Path) -> None: except Exception as e: console.print(f" [red]✗[/red] Failed to sync hooks: {e}") + # Sync skill permissions to platform settings + if skill_names: + console.print(" [dim]•[/dim] Syncing skill permissions...") + try: + permissions_count = adapter.sync_skill_permissions(project_path, skill_names) + stats["permissions"] += permissions_count + if permissions_count > 0: + console.print(f" [green]✓[/green] Added {permissions_count} permission(s)") + except Exception as e: + console.print(f" [red]✗[/red] Failed to sync permissions: {e}") + stats["platforms"] += 1 synced_adapters.append(adapter) @@ -175,6 +196,8 @@ def sync_skills(project_path: Path) -> None: table.add_row("Total skills", str(stats["skills"])) if stats["hooks"] > 0: table.add_row("Hooks synced", str(stats["hooks"])) + if stats["permissions"] > 0: + table.add_row("Permissions added", str(stats["permissions"])) console.print(table) console.print() diff --git a/src/deepwork/core/adapters.py b/src/deepwork/core/adapters.py index 9c52bde0..6154d650 100644 --- a/src/deepwork/core/adapters.py +++ b/src/deepwork/core/adapters.py @@ -241,6 +241,23 @@ def sync_hooks(self, project_path: Path, hooks: dict[str, list[dict[str, Any]]]) """ pass + def sync_skill_permissions(self, project_path: Path, skill_names: list[str]) -> int: + """ + Sync skill permissions to platform settings. + + This is an optional method that platforms can override to auto-approve + generated skills. Default implementation does nothing (no-op). + + Args: + project_path: Path to project root + skill_names: List of skill names to add permissions for + + Returns: + Number of permissions added (0 for platforms that don't support this) + """ + # Default: no-op for platforms that don't support skill permissions + 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 +361,65 @@ 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 sync_skill_permissions(self, project_path: Path, skill_names: list[str]) -> int: + """ + Sync skill permissions to Claude Code settings.json. + + Adds Skill() permissions to the allow list for all generated skills, + enabling auto-approval of skill invocations. + + Args: + project_path: Path to project root + skill_names: List of skill names to add permissions for + + Returns: + Number of permissions added + + Raises: + AdapterError: If sync fails + """ + if not skill_names: + return 0 + + settings_file = project_path / self.config_dir / "settings.json" + + # Load existing settings or create new + existing_settings: dict[str, Any] = {} + if settings_file.exists(): + try: + with open(settings_file, encoding="utf-8") as f: + existing_settings = json.load(f) + except (json.JSONDecodeError, OSError) as e: + raise AdapterError(f"Failed to read settings.json: {e}") from e + + # Ensure permissions structure exists + if "permissions" not in existing_settings: + existing_settings["permissions"] = {} + if "allow" not in existing_settings["permissions"]: + existing_settings["permissions"]["allow"] = [] + + # Add skill permissions if not already present + added_count = 0 + allow_list = existing_settings["permissions"]["allow"] + + for skill_name in skill_names: + # Use wildcard pattern to approve all variants of the skill + permission = f"Skill({skill_name}:*)" + if permission not in allow_list: + allow_list.append(permission) + added_count += 1 + + # Write back to settings.json if any permissions were added + if added_count > 0: + try: + settings_file.parent.mkdir(parents=True, exist_ok=True) + with open(settings_file, "w", encoding="utf-8") as f: + json.dump(existing_settings, f, indent=2) + except OSError as e: + raise AdapterError(f"Failed to write settings.json: {e}") from e + + return added_count + class GeminiAdapter(AgentAdapter): """Adapter for Gemini CLI. diff --git a/tests/integration/test_install_flow.py b/tests/integration/test_install_flow.py index beabf811..c1788d11 100644 --- a/tests/integration/test_install_flow.py +++ b/tests/integration/test_install_flow.py @@ -198,6 +198,71 @@ def test_install_creates_rules_directory(self, mock_claude_project: Path) -> Non assert result.exit_code == 0 assert ".deepwork/rules/ with example templates" in result.output + def test_install_adds_skill_permissions(self, mock_claude_project: Path) -> None: + """Test that install adds skill permissions to Claude settings.json.""" + import json + + runner = CliRunner() + + result = runner.invoke( + cli, + ["install", "--platform", "claude", "--path", str(mock_claude_project)], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "permission(s)" in result.output + + # Verify settings.json was created with permissions + settings_file = mock_claude_project / ".claude" / "settings.json" + assert settings_file.exists() + + settings = json.loads(settings_file.read_text()) + assert "permissions" in settings + assert "allow" in settings["permissions"] + + # Verify skill permissions were added for standard jobs + allow_list = settings["permissions"]["allow"] + assert any("Skill(deepwork_jobs:" in p for p in allow_list) + assert any("Skill(deepwork_jobs.define:" in p for p in allow_list) + assert any("Skill(deepwork_rules:" in p for p in allow_list) + + def test_install_preserves_existing_permissions(self, mock_claude_project: Path) -> None: + """Test that install preserves existing permissions in settings.json.""" + import json + + # Create existing settings.json with custom permissions + settings_file = mock_claude_project / ".claude" / "settings.json" + settings_file.write_text( + json.dumps( + { + "permissions": { + "allow": ["Bash(ls:*)", "Edit(./**)"] + } + } + ) + ) + + runner = CliRunner() + + result = runner.invoke( + cli, + ["install", "--platform", "claude", "--path", str(mock_claude_project)], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + + # Verify existing permissions are preserved + settings = json.loads(settings_file.read_text()) + allow_list = settings["permissions"]["allow"] + assert "Bash(ls:*)" in allow_list + assert "Edit(./**)" in allow_list + + # Verify skill permissions were added + assert any("Skill(deepwork_jobs:" in p for p in allow_list) + + # Verify rules directory was created rules_dir = mock_claude_project / ".deepwork" / "rules" assert rules_dir.exists() diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py index 4f2f3e17..59822fb3 100644 --- a/tests/unit/test_adapters.py +++ b/tests/unit/test_adapters.py @@ -44,6 +44,17 @@ def test_list_names_returns_all_names(self) -> None: assert "gemini" in names assert len(names) >= 2 # At least claude and gemini + def test_default_sync_skill_permissions_returns_zero(self, temp_dir: Path) -> None: + """Test that default sync_skill_permissions implementation returns 0.""" + # Use GeminiAdapter which doesn't override sync_skill_permissions + adapter = GeminiAdapter(temp_dir) + skill_names = ["test_skill", "test_skill.step"] + + count = adapter.sync_skill_permissions(temp_dir, skill_names) + + # Default implementation should return 0 (no-op) + assert count == 0 + class TestClaudeAdapter: """Tests for ClaudeAdapter.""" @@ -186,6 +197,80 @@ def test_sync_hooks_empty_hooks_returns_zero(self, temp_dir: Path) -> None: assert count == 0 + def test_sync_skill_permissions_creates_settings_file(self, temp_dir: Path) -> None: + """Test sync_skill_permissions creates settings.json when it doesn't exist.""" + (temp_dir / ".claude").mkdir() + adapter = ClaudeAdapter(temp_dir) + skill_names = ["my_job", "my_job.step_one", "my_job.step_two"] + + count = adapter.sync_skill_permissions(temp_dir, skill_names) + + assert count == 3 + 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 "Skill(my_job:*)" in settings["permissions"]["allow"] + assert "Skill(my_job.step_one:*)" in settings["permissions"]["allow"] + assert "Skill(my_job.step_two:*)" in settings["permissions"]["allow"] + + def test_sync_skill_permissions_merges_with_existing(self, temp_dir: Path) -> None: + """Test sync_skill_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( + { + "existing_key": "value", + "permissions": {"allow": ["Bash(ls:*)", "Edit(./**)"]}, + } + ) + ) + + adapter = ClaudeAdapter(temp_dir) + skill_names = ["my_job", "my_job.step_one"] + + count = adapter.sync_skill_permissions(temp_dir, skill_names) + + assert count == 2 + settings = json.loads(settings_file.read_text()) + assert settings["existing_key"] == "value" + assert "Bash(ls:*)" in settings["permissions"]["allow"] + assert "Edit(./**)" in settings["permissions"]["allow"] + assert "Skill(my_job:*)" in settings["permissions"]["allow"] + assert "Skill(my_job.step_one:*)" in settings["permissions"]["allow"] + + def test_sync_skill_permissions_avoids_duplicates(self, temp_dir: Path) -> None: + """Test sync_skill_permissions doesn't add duplicate permissions.""" + claude_dir = temp_dir / ".claude" + claude_dir.mkdir() + settings_file = claude_dir / "settings.json" + settings_file.write_text( + json.dumps({"permissions": {"allow": ["Skill(my_job:*)"]}})) + + adapter = ClaudeAdapter(temp_dir) + skill_names = ["my_job", "my_job.step_one"] + + count = adapter.sync_skill_permissions(temp_dir, skill_names) + + # Only step_one should be added, my_job already exists + assert count == 1 + settings = json.loads(settings_file.read_text()) + # Count occurrences of my_job permission + my_job_count = settings["permissions"]["allow"].count("Skill(my_job:*)") + assert my_job_count == 1 # Should only appear once + assert "Skill(my_job.step_one:*)" in settings["permissions"]["allow"] + + def test_sync_skill_permissions_empty_list_returns_zero(self, temp_dir: Path) -> None: + """Test sync_skill_permissions returns 0 for empty skill list.""" + adapter = ClaudeAdapter(temp_dir) + + count = adapter.sync_skill_permissions(temp_dir, []) + + assert count == 0 + class TestGeminiAdapter: """Tests for GeminiAdapter."""