diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f7239cb..2917b923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- New `export-job` command to transfer jobs to global Claude settings + - Copies job definitions from project `.deepwork/jobs/` to `~/.deepwork/jobs/` + - Generates skills in global `~/.claude/skills/` directory + - Updates `~/.claude/settings.json` with hooks and permissions + - Automatically handles doc specs referenced by the job + - Supports `--force` flag to overwrite without confirmation + - Makes jobs available across all Claude projects without per-project installation + - Command: `deepwork export-job [--path ] [--force]` ### Changed diff --git a/README.md b/README.md index 04cfd86d..f51cfda8 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,20 @@ Use the refine skill to update existing jobs: /deepwork_jobs.refine ``` +### 4. Share Jobs Globally + +Export jobs to your global Claude settings to use them across all projects: + +```bash +# Export a job to ~/.claude/ (global settings) +deepwork export-job my_workflow + +# Use the --force flag to overwrite without confirmation +deepwork export-job my_workflow --force +``` + +Once exported, the job is available in all your Claude projects without needing to install it per-project. + ## Example: Competitive Research Workflow Here's a sample 4-step workflow for competitive analysis: diff --git a/src/deepwork/cli/export_job.py b/src/deepwork/cli/export_job.py new file mode 100644 index 00000000..a1c37dd9 --- /dev/null +++ b/src/deepwork/cli/export_job.py @@ -0,0 +1,224 @@ +"""Export job command for DeepWork CLI.""" + +import shutil +from pathlib import Path + +import click +from rich.console import Console + +from deepwork.core.adapters import AgentAdapter +from deepwork.core.generator import SkillGenerator +from deepwork.core.hooks_syncer import JobHooks, sync_hooks_to_platform +from deepwork.core.parser import parse_job_definition +from deepwork.utils.fs import ensure_dir, fix_permissions + +console = Console() + + +class ExportError(Exception): + """Exception raised for export errors.""" + + pass + + +def _get_global_deepwork_dir() -> Path: + """ + Get the global DeepWork directory in the user's home. + + Returns: + Path to ~/.deepwork/ + """ + return Path.home() / ".deepwork" + + +def _get_global_claude_dir() -> Path: + """ + Get the global Claude settings directory in the user's home. + + Returns: + Path to ~/.claude/ + """ + return Path.home() / ".claude" + + +@click.command() +@click.argument("job_name") +@click.option( + "--path", + type=click.Path(exists=True, file_okay=False, path_type=Path), + default=".", + help="Path to project directory (default: current directory)", +) +@click.option( + "--force", + "-f", + is_flag=True, + help="Overwrite existing global job without confirmation", +) +def export_job(job_name: str, path: Path, force: bool) -> None: + """ + Export a job to global Claude settings. + + Copies the job definition, generates skills in ~/.claude/skills/, + and updates ~/.claude/settings.json with necessary hooks and permissions. + This makes the job available across all Claude projects. + """ + try: + _export_job(job_name, path, force) + except ExportError as e: + console.print(f"[red]Error:[/red] {e}") + raise click.Abort() from e + except Exception as e: + console.print(f"[red]Unexpected error:[/red] {e}") + raise + + +def _export_job(job_name: str, project_path: Path, force: bool) -> None: + """ + Export a job to global Claude settings. + + Args: + job_name: Name of the job to export + project_path: Path to project directory + force: If True, overwrite without confirmation + + Raises: + ExportError: If export fails + """ + console.print("\n[bold cyan]Exporting Job to Global Claude Settings[/bold cyan]\n") + + # Step 1: Validate job exists in project + console.print(f"[yellow]→[/yellow] Validating job '{job_name}'...") + project_job_dir = project_path / ".deepwork" / "jobs" / job_name + if not project_job_dir.exists(): + raise ExportError( + f"Job '{job_name}' not found in project.\n" + f"Expected location: {project_job_dir}\n" + "To create a new job, use the '/deepwork_jobs.define' skill in your AI assistant." + ) + + job_yml_path = project_job_dir / "job.yml" + if not job_yml_path.exists(): + raise ExportError( + f"Job definition not found: {job_yml_path}\n" + "Job directory exists but job.yml is missing." + ) + + # Parse the job definition + try: + job_def = parse_job_definition(project_job_dir) + console.print(f" [green]✓[/green] Job '{job_name}' validated (v{job_def.version})") + except Exception as e: + raise ExportError(f"Failed to parse job definition: {e}") from e + + # Step 2: Check if job already exists in global settings + global_deepwork_dir = _get_global_deepwork_dir() + global_job_dir = global_deepwork_dir / "jobs" / job_name + + if global_job_dir.exists() and not force: + console.print( + f"[yellow]⚠[/yellow] Job '{job_name}' already exists in global settings: {global_job_dir}" + ) + if not click.confirm("Do you want to overwrite it?", default=False): + console.print("[yellow]Export cancelled.[/yellow]") + raise ExportError("Export cancelled by user") + + # Step 3: Copy job to global deepwork directory + console.print("[yellow]→[/yellow] Copying job to global DeepWork directory...") + ensure_dir(global_deepwork_dir / "jobs") + + # Remove existing if present + if global_job_dir.exists(): + shutil.rmtree(global_job_dir) + + # Copy the entire job directory + try: + shutil.copytree(project_job_dir, global_job_dir) + fix_permissions(global_job_dir) + console.print(f" [green]✓[/green] Job copied to {global_job_dir}") + except Exception as e: + raise ExportError(f"Failed to copy job: {e}") from e + + # Step 4: Copy doc specs if present + project_doc_specs_dir = project_path / ".deepwork" / "doc_specs" + if project_doc_specs_dir.exists(): + global_doc_specs_dir = global_deepwork_dir / "doc_specs" + ensure_dir(global_doc_specs_dir) + + # Find doc specs referenced by this job + doc_specs_to_copy = [] + for step in job_def.steps: + for output in step.outputs: + if output.doc_spec: + doc_spec_file = project_doc_specs_dir / Path(output.doc_spec).name + if doc_spec_file.exists() and doc_spec_file not in doc_specs_to_copy: + doc_specs_to_copy.append(doc_spec_file) + + if doc_specs_to_copy: + console.print("[yellow]→[/yellow] Copying doc specs...") + for doc_spec_file in doc_specs_to_copy: + dest_file = global_doc_specs_dir / doc_spec_file.name + shutil.copy(doc_spec_file, dest_file) + fix_permissions(dest_file) + console.print(f" [green]✓[/green] Copied {doc_spec_file.name}") + + # Step 5: Generate skills in global Claude directory + console.print("[yellow]→[/yellow] Generating skills for Claude Code...") + global_claude_dir = _get_global_claude_dir() + + # Create global Claude directory if it doesn't exist + if not global_claude_dir.exists(): + ensure_dir(global_claude_dir) + console.print(f" [green]✓[/green] Created {global_claude_dir}") + + global_claude_skills_dir = global_claude_dir / "skills" + ensure_dir(global_claude_skills_dir) + + # Generate skills using Claude adapter + claude_adapter = AgentAdapter.get("claude")(project_root=Path.home()) + generator = SkillGenerator() + + try: + skill_paths = generator.generate_all_skills( + job_def, claude_adapter, global_claude_dir, project_root=Path.home() + ) + console.print(f" [green]✓[/green] Generated {len(skill_paths)} skills in {global_claude_skills_dir}") + except Exception as e: + raise ExportError(f"Failed to generate skills: {e}") from e + + # Step 6: Sync hooks to global Claude settings + job_hooks = JobHooks.from_job_dir(global_job_dir) + if job_hooks: + console.print("[yellow]→[/yellow] Syncing hooks to global Claude settings...") + try: + hooks_count = sync_hooks_to_platform(Path.home(), claude_adapter, [job_hooks]) + if hooks_count > 0: + console.print(f" [green]✓[/green] Synced {hooks_count} hook(s)") + except Exception as e: + raise ExportError(f"Failed to sync hooks: {e}") from e + + # Step 7: Sync permissions to global Claude settings + console.print("[yellow]→[/yellow] Syncing permissions to global Claude settings...") + try: + # Add global deepwork directory permissions + perms_count = claude_adapter.sync_permissions(Path.home()) + if perms_count > 0: + console.print(f" [green]✓[/green] Added {perms_count} base permission(s)") + + # Add skill permissions + if skill_paths and hasattr(claude_adapter, "add_skill_permissions"): + skill_perms_count = claude_adapter.add_skill_permissions(Path.home(), skill_paths) + if skill_perms_count > 0: + console.print(f" [green]✓[/green] Added {skill_perms_count} skill permission(s)") + except Exception as e: + raise ExportError(f"Failed to sync permissions: {e}") from e + + # Success message + console.print() + console.print(f"[bold green]✓ Job '{job_name}' exported successfully to global Claude settings![/bold green]") + console.print() + console.print("[bold]The job is now available across all your Claude projects.[/bold]") + console.print() + console.print("[bold]To use the job:[/bold]") + console.print(f" Start Claude and use: [cyan]/{job_name}[/cyan] or [cyan]/{job_name}.[/cyan]") + console.print() diff --git a/src/deepwork/cli/main.py b/src/deepwork/cli/main.py index b503ea9a..9cddc864 100644 --- a/src/deepwork/cli/main.py +++ b/src/deepwork/cli/main.py @@ -14,6 +14,7 @@ def cli() -> None: # Import commands +from deepwork.cli.export_job import export_job # noqa: E402 from deepwork.cli.hook import hook # noqa: E402 from deepwork.cli.install import install # noqa: E402 from deepwork.cli.rules import rules # noqa: E402 @@ -23,6 +24,7 @@ def cli() -> None: cli.add_command(sync) cli.add_command(hook) cli.add_command(rules) +cli.add_command(export_job) if __name__ == "__main__": diff --git a/tests/integration/test_export_job.py b/tests/integration/test_export_job.py new file mode 100644 index 00000000..c0519a84 --- /dev/null +++ b/tests/integration/test_export_job.py @@ -0,0 +1,264 @@ +"""Integration tests for the export-job command.""" + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from deepwork.cli.main import cli + + +@pytest.fixture +def mock_installed_project(mock_claude_project: Path) -> Path: + """Create a mock project with DeepWork already installed and a job defined.""" + # Install DeepWork first + runner = CliRunner() + result = runner.invoke( + cli, + ["install", "--platform", "claude", "--path", str(mock_claude_project)], + catch_exceptions=False, + ) + assert result.exit_code == 0 + + # Create a simple test job + jobs_dir = mock_claude_project / ".deepwork" / "jobs" + test_job_dir = jobs_dir / "test_export_job" + test_job_dir.mkdir(exist_ok=True) + + job_yml_content = """name: test_export_job +version: "1.0.0" +summary: "Test job for export functionality" +description: | + This is a test job used to verify the export-job command. + +steps: + - id: step_one + name: "First Step" + description: "A simple first step" + instructions_file: steps/step_one.md + inputs: + - name: input_param + description: "An input parameter" + outputs: + - output.txt + dependencies: [] +""" + (test_job_dir / "job.yml").write_text(job_yml_content) + + # Create steps directory + steps_dir = test_job_dir / "steps" + steps_dir.mkdir(exist_ok=True) + (steps_dir / "step_one.md").write_text("# Step One Instructions\nDo something.") + + # Note: We don't need to run sync here, the export command will generate skills itself + + return mock_claude_project + + +@pytest.fixture +def clean_global_claude(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Mock the global Claude directory to a temporary location.""" + fake_home = tmp_path / "fake_home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: fake_home) + return fake_home + + +class TestExportJobCommand: + """Integration tests for 'deepwork export-job' command.""" + + def test_export_job_basic( + self, mock_installed_project: Path, clean_global_claude: Path + ) -> None: + """Test basic job export functionality.""" + runner = CliRunner() + + result = runner.invoke( + cli, + ["export-job", "test_export_job", "--path", str(mock_installed_project)], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "Exporting Job to Global Claude Settings" in result.output + assert "Job 'test_export_job' validated" in result.output + assert "Job copied to" in result.output + assert "Generated" in result.output + assert "skills" in result.output + assert "exported successfully" in result.output + + # Verify job was copied to global directory + global_job_dir = clean_global_claude / ".deepwork" / "jobs" / "test_export_job" + assert global_job_dir.exists() + assert (global_job_dir / "job.yml").exists() + assert (global_job_dir / "steps" / "step_one.md").exists() + + # Verify skills were generated in global Claude directory + global_skills_dir = clean_global_claude / ".claude" / "skills" + assert global_skills_dir.exists() + assert (global_skills_dir / "test_export_job" / "SKILL.md").exists() + assert (global_skills_dir / "test_export_job.step_one" / "SKILL.md").exists() + + # Verify global Claude settings.json was updated + global_settings_file = clean_global_claude / ".claude" / "settings.json" + assert global_settings_file.exists() + with open(global_settings_file, encoding="utf-8") as f: + settings = json.load(f) + assert "permissions" in settings + assert "allow" in settings["permissions"] + # Check for DeepWork permissions + assert any("deepwork" in perm for perm in settings["permissions"]["allow"]) + + def test_export_job_not_found(self, mock_claude_project: Path, clean_global_claude: Path) -> None: + """Test exporting a non-existent job.""" + runner = CliRunner() + + result = runner.invoke( + cli, + ["export-job", "nonexistent_job", "--path", str(mock_claude_project)], + ) + + assert result.exit_code != 0 + assert "Job 'nonexistent_job' not found" in result.output + + def test_export_job_overwrite_prompt( + self, mock_installed_project: Path, clean_global_claude: Path + ) -> None: + """Test overwrite prompt when job already exists globally.""" + runner = CliRunner() + + # Export job first time + result = runner.invoke( + cli, + ["export-job", "test_export_job", "--path", str(mock_installed_project)], + catch_exceptions=False, + ) + assert result.exit_code == 0 + + # Try to export again without force flag (answer 'n' to prompt) + result = runner.invoke( + cli, + ["export-job", "test_export_job", "--path", str(mock_installed_project)], + input="n\n", + ) + assert result.exit_code != 0 # Aborted + assert "already exists in global settings" in result.output + assert "Export cancelled" in result.output + + def test_export_job_overwrite_with_confirmation( + self, mock_installed_project: Path, clean_global_claude: Path + ) -> None: + """Test overwrite with user confirmation.""" + runner = CliRunner() + + # Export job first time + result = runner.invoke( + cli, + ["export-job", "test_export_job", "--path", str(mock_installed_project)], + catch_exceptions=False, + ) + assert result.exit_code == 0 + + # Modify the global job to verify it gets overwritten + global_job_yml = clean_global_claude / ".deepwork" / "jobs" / "test_export_job" / "job.yml" + original_content = global_job_yml.read_text() + global_job_yml.write_text("modified: true\n") + + # Try to export again with confirmation (answer 'y' to prompt) + result = runner.invoke( + cli, + ["export-job", "test_export_job", "--path", str(mock_installed_project)], + input="y\n", + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert "already exists in global settings" in result.output + assert "exported successfully" in result.output + + # Verify the file was overwritten (not modified) + assert global_job_yml.read_text() == original_content + + def test_export_job_force_flag( + self, mock_installed_project: Path, clean_global_claude: Path + ) -> None: + """Test export with force flag skips confirmation.""" + runner = CliRunner() + + # Export job first time + result = runner.invoke( + cli, + ["export-job", "test_export_job", "--path", str(mock_installed_project)], + catch_exceptions=False, + ) + assert result.exit_code == 0 + + # Export again with --force flag + result = runner.invoke( + cli, + ["export-job", "test_export_job", "--path", str(mock_installed_project), "--force"], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert "already exists" not in result.output # No prompt shown + assert "exported successfully" in result.output + + def test_export_job_with_doc_specs( + self, mock_installed_project: Path, clean_global_claude: Path + ) -> None: + """Test exporting a job with doc specs.""" + # Create a job with doc specs + jobs_dir = mock_installed_project / ".deepwork" / "jobs" + test_job_dir = jobs_dir / "job_with_docspec" + test_job_dir.mkdir(exist_ok=True) + + job_yml_content = """name: job_with_docspec +version: "1.0.0" +summary: "Test job with doc specs" +description: "Test job with doc specs" + +steps: + - id: create_report + name: "Create Report" + description: "Creates a report" + instructions_file: steps/create_report.md + inputs: [] + outputs: + - file: report.md + doc_spec: .deepwork/doc_specs/report_spec.md + dependencies: [] +""" + (test_job_dir / "job.yml").write_text(job_yml_content) + + # Create steps directory + steps_dir = test_job_dir / "steps" + steps_dir.mkdir(exist_ok=True) + (steps_dir / "create_report.md").write_text("# Create Report\nGenerate a report.") + + # Create doc spec + doc_specs_dir = mock_installed_project / ".deepwork" / "doc_specs" + doc_specs_dir.mkdir(exist_ok=True) + doc_spec_content = """--- +name: Report Specification +--- +# Quality Criteria +- Must have a title +- Must have content +""" + (doc_specs_dir / "report_spec.md").write_text(doc_spec_content) + + # Export the job (no need to sync first) + runner = CliRunner() + result = runner.invoke( + cli, + ["export-job", "job_with_docspec", "--path", str(mock_installed_project)], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert "Copying doc specs" in result.output + assert "Copied report_spec.md" in result.output + + # Verify doc spec was copied + global_doc_spec = clean_global_claude / ".deepwork" / "doc_specs" / "report_spec.md" + assert global_doc_spec.exists() + assert "Quality Criteria" in global_doc_spec.read_text()