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
27 changes: 26 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 0 additions & 4 deletions .claude/skills/manual_tests.run_not_fire_tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
4 changes: 0 additions & 4 deletions .gemini/skills/manual_tests/run_not_fire_tests.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
24 changes: 24 additions & 0 deletions src/deepwork/cli/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,15 @@ 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:
try:
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:
Expand All @@ -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)

Expand Down
205 changes: 205 additions & 0 deletions src/deepwork/core/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
Expand Down
Loading