diff --git a/.claude/skills/deepwork_jobs.implement/SKILL.md b/.claude/skills/deepwork_jobs.implement/SKILL.md index a0c1d388..48f2bfbd 100644 --- a/.claude/skills/deepwork_jobs.implement/SKILL.md +++ b/.claude/skills/deepwork_jobs.implement/SKILL.md @@ -32,31 +32,52 @@ Generate the DeepWork job directory structure and instruction files for each ste Read the `job.yml` specification file and create all the necessary files to make the job functional, including directory structure and step instruction files. Then sync the commands to make them available. -### Step 1: Create Directory Structure Using Script +### Step 1: Determine Installation Scope -Run the `make_new_job.sh` script to create the standard directory structure: +**Ask the user where they want to install this job using structured questions:** +Present two options: +1. **Local** - Install in this project only (`.deepwork/jobs/`) +2. **Global** - Install for all projects in your user config (`~/.config/deepwork/jobs/`) + +**Guidance for users:** +- Choose **local** if the job is specific to this project or you want to version control it +- Choose **global** if the job is reusable across multiple projects (e.g., a general "code review" or "documentation" workflow) + +### Step 2: Create Directory Structure Using Script + +Run the `make_new_job.sh` script to create the standard directory structure with the chosen scope: + +**For local installation:** ```bash -.deepwork/jobs/deepwork_jobs/make_new_job.sh [job_name] +.deepwork/jobs/deepwork_jobs/make_new_job.sh [job_name] --scope local ``` -This creates: -- `.deepwork/jobs/[job_name]/` - Main job directory -- `.deepwork/jobs/[job_name]/steps/` - Step instruction files -- `.deepwork/jobs/[job_name]/hooks/` - Custom validation scripts (with .gitkeep) -- `.deepwork/jobs/[job_name]/templates/` - Example file formats (with .gitkeep) -- `.deepwork/jobs/[job_name]/AGENTS.md` - Job management guidance - -**Note**: If the directory already exists (e.g., job.yml was created by define step), you can skip this step or manually create the additional directories: +**For global installation:** ```bash -mkdir -p .deepwork/jobs/[job_name]/hooks .deepwork/jobs/[job_name]/templates -touch .deepwork/jobs/[job_name]/hooks/.gitkeep .deepwork/jobs/[job_name]/templates/.gitkeep +.deepwork/jobs/deepwork_jobs/make_new_job.sh [job_name] --scope global ``` -### Step 2: Read and Validate the Specification +This creates: +- `[scope_path]/[job_name]/` - Main job directory +- `[scope_path]/[job_name]/steps/` - Step instruction files +- `[scope_path]/[job_name]/hooks/` - Custom validation scripts (with .gitkeep) +- `[scope_path]/[job_name]/templates/` - Example file formats (with .gitkeep) +- `[scope_path]/[job_name]/AGENTS.md` - Job management guidance + +Where `[scope_path]` is: +- `.deepwork/jobs` for local scope +- `~/.config/deepwork/jobs` (or `$XDG_CONFIG_HOME/deepwork/jobs`) for global scope + +**Note**: If the directory already exists (e.g., job.yml was created by define step), you can skip this step or manually create the additional directories in the appropriate scope location. + +### Step 3: Read and Validate the Specification 1. **Locate the job.yml file** - - Read `.deepwork/jobs/[job_name]/job.yml` from the review_job_spec step + - Read the job.yml from the review_job_spec step + - The file location depends on the chosen scope: + - Local: `.deepwork/jobs/[job_name]/job.yml` + - Global: `~/.config/deepwork/jobs/[job_name]/job.yml` (or `$XDG_CONFIG_HOME/deepwork/jobs/[job_name]/job.yml`) - Parse the YAML content 2. **Validate the specification** @@ -70,9 +91,11 @@ touch .deepwork/jobs/[job_name]/hooks/.gitkeep .deepwork/jobs/[job_name]/templat - List of all steps with their details - Understand the workflow structure -### Step 3: Generate Step Instruction Files +### Step 4: Generate Step Instruction Files -For each step in the job.yml, create a comprehensive instruction file at `.deepwork/jobs/[job_name]/steps/[step_id].md`. +For each step in the job.yml, create a comprehensive instruction file at the appropriate scope location: +- Local: `.deepwork/jobs/[job_name]/steps/[step_id].md` +- Global: `~/.config/deepwork/jobs/[job_name]/steps/[step_id].md` **Template reference**: See `.deepwork/jobs/deepwork_jobs/templates/step_instruction.md.template` for the standard structure. diff --git a/.deepwork/jobs/deepwork_jobs/make_new_job.sh b/.deepwork/jobs/deepwork_jobs/make_new_job.sh index c561d6d2..d3abf081 100755 --- a/.deepwork/jobs/deepwork_jobs/make_new_job.sh +++ b/.deepwork/jobs/deepwork_jobs/make_new_job.sh @@ -37,31 +37,58 @@ validate_job_name() { # Main script main() { if [[ $# -lt 1 ]]; then - echo "Usage: $0 " + echo "Usage: $0 [--scope local|global]" echo "" echo "Creates the directory structure for a new DeepWork job." echo "" echo "Arguments:" echo " job_name Name of the job (lowercase, underscores allowed)" + echo " --scope Installation scope: 'local' (project) or 'global' (XDG config)" + echo " Default: local" echo "" - echo "Example:" + echo "Examples:" echo " $0 competitive_research" + echo " $0 competitive_research --scope global" exit 1 fi local job_name="$1" + local scope="local" + + # Parse optional --scope argument + if [[ $# -ge 2 ]] && [[ "$2" == "--scope" ]]; then + if [[ $# -ge 3 ]]; then + scope="$3" + if [[ "$scope" != "local" && "$scope" != "global" ]]; then + error "Invalid scope '$scope'. Must be 'local' or 'global'." + fi + else + error "--scope requires a value (local or global)" + fi + fi + validate_job_name "$job_name" - # Determine the base path - look for .deepwork directory + # Determine the base path based on scope local base_path - if [[ -d ".deepwork/jobs" ]]; then - base_path=".deepwork/jobs" - elif [[ -d "../.deepwork/jobs" ]]; then - base_path="../.deepwork/jobs" - else - # Create from current directory - base_path=".deepwork/jobs" + if [[ "$scope" == "global" ]]; then + # Use XDG config directory for global jobs + local xdg_config="${XDG_CONFIG_HOME:-$HOME/.config}" + base_path="${xdg_config}/deepwork/jobs" mkdir -p "$base_path" + info "Creating global job in $base_path" + else + # Local scope - look for .deepwork directory + if [[ -d ".deepwork/jobs" ]]; then + base_path=".deepwork/jobs" + elif [[ -d "../.deepwork/jobs" ]]; then + base_path="../.deepwork/jobs" + else + # Create from current directory + base_path=".deepwork/jobs" + mkdir -p "$base_path" + fi + info "Creating local job in $base_path" fi local job_path="${base_path}/${job_name}" diff --git a/.deepwork/jobs/deepwork_jobs/steps/implement.md b/.deepwork/jobs/deepwork_jobs/steps/implement.md index 749c8c6f..a047cceb 100644 --- a/.deepwork/jobs/deepwork_jobs/steps/implement.md +++ b/.deepwork/jobs/deepwork_jobs/steps/implement.md @@ -8,31 +8,52 @@ Generate the DeepWork job directory structure and instruction files for each ste Read the `job.yml` specification file and create all the necessary files to make the job functional, including directory structure and step instruction files. Then sync the commands to make them available. -### Step 1: Create Directory Structure Using Script +### Step 1: Determine Installation Scope -Run the `make_new_job.sh` script to create the standard directory structure: +**Ask the user where they want to install this job using structured questions:** +Present two options: +1. **Local** - Install in this project only (`.deepwork/jobs/`) +2. **Global** - Install for all projects in your user config (`~/.config/deepwork/jobs/`) + +**Guidance for users:** +- Choose **local** if the job is specific to this project or you want to version control it +- Choose **global** if the job is reusable across multiple projects (e.g., a general "code review" or "documentation" workflow) + +### Step 2: Create Directory Structure Using Script + +Run the `make_new_job.sh` script to create the standard directory structure with the chosen scope: + +**For local installation:** ```bash -.deepwork/jobs/deepwork_jobs/make_new_job.sh [job_name] +.deepwork/jobs/deepwork_jobs/make_new_job.sh [job_name] --scope local ``` -This creates: -- `.deepwork/jobs/[job_name]/` - Main job directory -- `.deepwork/jobs/[job_name]/steps/` - Step instruction files -- `.deepwork/jobs/[job_name]/hooks/` - Custom validation scripts (with .gitkeep) -- `.deepwork/jobs/[job_name]/templates/` - Example file formats (with .gitkeep) -- `.deepwork/jobs/[job_name]/AGENTS.md` - Job management guidance - -**Note**: If the directory already exists (e.g., job.yml was created by define step), you can skip this step or manually create the additional directories: +**For global installation:** ```bash -mkdir -p .deepwork/jobs/[job_name]/hooks .deepwork/jobs/[job_name]/templates -touch .deepwork/jobs/[job_name]/hooks/.gitkeep .deepwork/jobs/[job_name]/templates/.gitkeep +.deepwork/jobs/deepwork_jobs/make_new_job.sh [job_name] --scope global ``` -### Step 2: Read and Validate the Specification +This creates: +- `[scope_path]/[job_name]/` - Main job directory +- `[scope_path]/[job_name]/steps/` - Step instruction files +- `[scope_path]/[job_name]/hooks/` - Custom validation scripts (with .gitkeep) +- `[scope_path]/[job_name]/templates/` - Example file formats (with .gitkeep) +- `[scope_path]/[job_name]/AGENTS.md` - Job management guidance + +Where `[scope_path]` is: +- `.deepwork/jobs` for local scope +- `~/.config/deepwork/jobs` (or `$XDG_CONFIG_HOME/deepwork/jobs`) for global scope + +**Note**: If the directory already exists (e.g., job.yml was created by define step), you can skip this step or manually create the additional directories in the appropriate scope location. + +### Step 3: Read and Validate the Specification 1. **Locate the job.yml file** - - Read `.deepwork/jobs/[job_name]/job.yml` from the review_job_spec step + - Read the job.yml from the review_job_spec step + - The file location depends on the chosen scope: + - Local: `.deepwork/jobs/[job_name]/job.yml` + - Global: `~/.config/deepwork/jobs/[job_name]/job.yml` (or `$XDG_CONFIG_HOME/deepwork/jobs/[job_name]/job.yml`) - Parse the YAML content 2. **Validate the specification** @@ -46,9 +67,11 @@ touch .deepwork/jobs/[job_name]/hooks/.gitkeep .deepwork/jobs/[job_name]/templat - List of all steps with their details - Understand the workflow structure -### Step 3: Generate Step Instruction Files +### Step 4: Generate Step Instruction Files -For each step in the job.yml, create a comprehensive instruction file at `.deepwork/jobs/[job_name]/steps/[step_id].md`. +For each step in the job.yml, create a comprehensive instruction file at the appropriate scope location: +- Local: `.deepwork/jobs/[job_name]/steps/[step_id].md` +- Global: `~/.config/deepwork/jobs/[job_name]/steps/[step_id].md` **Template reference**: See `.deepwork/jobs/deepwork_jobs/templates/step_instruction.md.template` for the standard structure. diff --git a/.gemini/skills/deepwork_jobs/implement.toml b/.gemini/skills/deepwork_jobs/implement.toml index 484f4bcc..4fe03c49 100644 --- a/.gemini/skills/deepwork_jobs/implement.toml +++ b/.gemini/skills/deepwork_jobs/implement.toml @@ -32,31 +32,52 @@ Generate the DeepWork job directory structure and instruction files for each ste Read the `job.yml` specification file and create all the necessary files to make the job functional, including directory structure and step instruction files. Then sync the commands to make them available. -### Step 1: Create Directory Structure Using Script +### Step 1: Determine Installation Scope -Run the `make_new_job.sh` script to create the standard directory structure: +**Ask the user where they want to install this job using structured questions:** +Present two options: +1. **Local** - Install in this project only (`.deepwork/jobs/`) +2. **Global** - Install for all projects in your user config (`~/.config/deepwork/jobs/`) + +**Guidance for users:** +- Choose **local** if the job is specific to this project or you want to version control it +- Choose **global** if the job is reusable across multiple projects (e.g., a general "code review" or "documentation" workflow) + +### Step 2: Create Directory Structure Using Script + +Run the `make_new_job.sh` script to create the standard directory structure with the chosen scope: + +**For local installation:** ```bash -.deepwork/jobs/deepwork_jobs/make_new_job.sh [job_name] +.deepwork/jobs/deepwork_jobs/make_new_job.sh [job_name] --scope local ``` -This creates: -- `.deepwork/jobs/[job_name]/` - Main job directory -- `.deepwork/jobs/[job_name]/steps/` - Step instruction files -- `.deepwork/jobs/[job_name]/hooks/` - Custom validation scripts (with .gitkeep) -- `.deepwork/jobs/[job_name]/templates/` - Example file formats (with .gitkeep) -- `.deepwork/jobs/[job_name]/AGENTS.md` - Job management guidance - -**Note**: If the directory already exists (e.g., job.yml was created by define step), you can skip this step or manually create the additional directories: +**For global installation:** ```bash -mkdir -p .deepwork/jobs/[job_name]/hooks .deepwork/jobs/[job_name]/templates -touch .deepwork/jobs/[job_name]/hooks/.gitkeep .deepwork/jobs/[job_name]/templates/.gitkeep +.deepwork/jobs/deepwork_jobs/make_new_job.sh [job_name] --scope global ``` -### Step 2: Read and Validate the Specification +This creates: +- `[scope_path]/[job_name]/` - Main job directory +- `[scope_path]/[job_name]/steps/` - Step instruction files +- `[scope_path]/[job_name]/hooks/` - Custom validation scripts (with .gitkeep) +- `[scope_path]/[job_name]/templates/` - Example file formats (with .gitkeep) +- `[scope_path]/[job_name]/AGENTS.md` - Job management guidance + +Where `[scope_path]` is: +- `.deepwork/jobs` for local scope +- `~/.config/deepwork/jobs` (or `$XDG_CONFIG_HOME/deepwork/jobs`) for global scope + +**Note**: If the directory already exists (e.g., job.yml was created by define step), you can skip this step or manually create the additional directories in the appropriate scope location. + +### Step 3: Read and Validate the Specification 1. **Locate the job.yml file** - - Read `.deepwork/jobs/[job_name]/job.yml` from the review_job_spec step + - Read the job.yml from the review_job_spec step + - The file location depends on the chosen scope: + - Local: `.deepwork/jobs/[job_name]/job.yml` + - Global: `~/.config/deepwork/jobs/[job_name]/job.yml` (or `$XDG_CONFIG_HOME/deepwork/jobs/[job_name]/job.yml`) - Parse the YAML content 2. **Validate the specification** @@ -70,9 +91,11 @@ touch .deepwork/jobs/[job_name]/hooks/.gitkeep .deepwork/jobs/[job_name]/templat - List of all steps with their details - Understand the workflow structure -### Step 3: Generate Step Instruction Files +### Step 4: Generate Step Instruction Files -For each step in the job.yml, create a comprehensive instruction file at `.deepwork/jobs/[job_name]/steps/[step_id].md`. +For each step in the job.yml, create a comprehensive instruction file at the appropriate scope location: +- Local: `.deepwork/jobs/[job_name]/steps/[step_id].md` +- Global: `~/.config/deepwork/jobs/[job_name]/steps/[step_id].md` **Template reference**: See `.deepwork/jobs/deepwork_jobs/templates/step_instruction.md.template` for the standard structure. diff --git a/src/deepwork/cli/main.py b/src/deepwork/cli/main.py index b503ea9a..700b7815 100644 --- a/src/deepwork/cli/main.py +++ b/src/deepwork/cli/main.py @@ -16,11 +16,13 @@ def cli() -> None: # Import commands from deepwork.cli.hook import hook # noqa: E402 from deepwork.cli.install import install # noqa: E402 +from deepwork.cli.port import port # noqa: E402 from deepwork.cli.rules import rules # noqa: E402 from deepwork.cli.sync import sync # noqa: E402 cli.add_command(install) cli.add_command(sync) +cli.add_command(port) cli.add_command(hook) cli.add_command(rules) diff --git a/src/deepwork/cli/port.py b/src/deepwork/cli/port.py new file mode 100644 index 00000000..8f9769e3 --- /dev/null +++ b/src/deepwork/cli/port.py @@ -0,0 +1,178 @@ +"""Port command for moving jobs between scopes and sources.""" + +import shutil +from pathlib import Path + +import click +from rich.console import Console +from rich.prompt import Prompt + +from deepwork.utils.fs import ensure_dir, fix_permissions +from deepwork.utils.job_location import JobScope, get_jobs_dir +from deepwork.utils.xdg import ensure_global_jobs_dir + +console = Console() + + +class PortError(Exception): + """Exception raised for port errors.""" + + pass + + +@click.command() +@click.option( + "--path", + type=click.Path(exists=True, file_okay=False, path_type=Path), + default=".", + help="Path to project directory (default: current directory)", +) +def port(path: Path) -> None: + """ + Port a DeepWork job between local and global scopes. + + Allows you to move a job from local project storage to global storage + (or vice versa), or import jobs from other sources. + """ + try: + _port_job(path) + except PortError 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 _port_job(project_path: Path) -> None: + """ + Port a job between scopes or from other sources. + + Args: + project_path: Path to project directory + + Raises: + PortError: If porting fails + """ + console.print("\n[bold cyan]DeepWork Job Porting[/bold cyan]\n") + + # Step 1: Ask where to port FROM + console.print("[yellow]→[/yellow] Select source location:") + console.print(" 1. Local project (.deepwork/jobs/)") + console.print(" 2. Global user config (~/.config/deepwork/jobs/)") + console.print(" 3. Standard DeepWork library (src/deepwork/standard_jobs/)") + console.print(" 4. GitHub remote (not yet implemented)") + console.print() + + source_choice = Prompt.ask( + "Source", choices=["1", "2", "3", "4"], default="1", show_choices=False + ) + + # Determine source directory + if source_choice == "1": + source_dir = get_jobs_dir(project_path, JobScope.LOCAL) + source_name = "local project" + elif source_choice == "2": + source_dir = get_jobs_dir(project_path, JobScope.GLOBAL) + source_name = "global user config" + elif source_choice == "3": + # Find standard jobs in the installed package + import deepwork.standard_jobs + + standard_jobs_dir = Path(deepwork.standard_jobs.__file__).parent + source_dir = standard_jobs_dir + source_name = "standard library" + else: # choice == "4" + raise PortError( + "GitHub remote import is not yet implemented. " + "Please manually clone the job or request this feature." + ) + + if not source_dir.exists(): + raise PortError(f"Source directory does not exist: {source_dir}") + + # Step 2: List available jobs in source + console.print(f"\n[yellow]→[/yellow] Available jobs in {source_name}:") + available_jobs = [ + d for d in source_dir.iterdir() if d.is_dir() and (d / "job.yml").exists() + ] + + if not available_jobs: + raise PortError(f"No jobs found in {source_name}") + + for i, job_dir in enumerate(available_jobs, 1): + console.print(f" {i}. {job_dir.name}") + + console.print() + job_index = Prompt.ask( + "Select job number", + choices=[str(i) for i in range(1, len(available_jobs) + 1)], + default="1", + show_choices=False, + ) + + selected_job = available_jobs[int(job_index) - 1] + job_name = selected_job.name + + console.print(f"\n[green]✓[/green] Selected: {job_name}") + + # Step 3: Ask where to port TO + console.print("\n[yellow]→[/yellow] Select destination:") + console.print(" 1. Local project (.deepwork/jobs/)") + console.print(" 2. Global user config (~/.config/deepwork/jobs/)") + console.print() + + dest_choice = Prompt.ask("Destination", choices=["1", "2"], default="1", show_choices=False) + + # Determine destination directory + if dest_choice == "1": + dest_scope = JobScope.LOCAL + dest_name = "local project" + else: + dest_scope = JobScope.GLOBAL + dest_name = "global user config" + ensure_global_jobs_dir() + + dest_dir = get_jobs_dir(project_path, dest_scope) + ensure_dir(dest_dir) + + dest_job_path = dest_dir / job_name + + # Step 4: Check if destination already exists + if dest_job_path.exists(): + console.print(f"\n[yellow]⚠[/yellow] Job '{job_name}' already exists in {dest_name}") + overwrite = Prompt.ask("Overwrite?", choices=["y", "n"], default="n") + if overwrite.lower() != "y": + console.print("[dim]Porting cancelled.[/dim]") + return + shutil.rmtree(dest_job_path) + + # Step 5: Copy the job + console.print(f"\n[yellow]→[/yellow] Copying job to {dest_name}...") + + try: + shutil.copytree(selected_job, dest_job_path) + fix_permissions(dest_job_path) + console.print(f" [green]✓[/green] Job copied to {dest_job_path}") + except Exception as e: + raise PortError(f"Failed to copy job: {e}") from e + + # Step 6: Success message + console.print() + console.print(f"[bold green]✓ Successfully ported {job_name} to {dest_name}![/bold green]") + console.print() + console.print("[bold]Next steps:[/bold]") + console.print(" 1. Run [cyan]deepwork sync[/cyan] to regenerate skills") + + # Display the destination path safely + if dest_scope == JobScope.LOCAL: + try: + relative_path = dest_job_path.relative_to(project_path) + console.print(f" 2. The job is now available in {dest_name} ({relative_path})") + except ValueError: + # Fallback if path is not relative + console.print(f" 2. The job is now available in {dest_name} ({dest_job_path})") + else: + console.print(f" 2. The job is now available in {dest_name} ({dest_job_path})") + + console.print() diff --git a/src/deepwork/cli/sync.py b/src/deepwork/cli/sync.py index 03c47a30..2246e030 100644 --- a/src/deepwork/cli/sync.py +++ b/src/deepwork/cli/sync.py @@ -80,19 +80,38 @@ def sync_skills(project_path: Path) -> None: console.print("[bold cyan]Syncing DeepWork Skills[/bold cyan]\n") - # Discover jobs - jobs_dir = deepwork_dir / "jobs" - if not jobs_dir.exists(): - job_dirs = [] - else: - job_dirs = [d for d in jobs_dir.iterdir() if d.is_dir() and (d / "job.yml").exists()] + # Discover jobs from both local and global locations + from deepwork.utils.job_location import discover_all_jobs_dirs + + all_job_dirs: list[Path] = [] + locations = discover_all_jobs_dirs(project_path) + + for scope, jobs_dir in locations: + if not jobs_dir.exists(): + continue + + scope_name = scope.value # Use the enum value directly + job_dirs_in_scope = [ + d for d in jobs_dir.iterdir() if d.is_dir() and (d / "job.yml").exists() + ] - console.print(f"[yellow]→[/yellow] Found {len(job_dirs)} job(s) to sync") + if job_dirs_in_scope: + console.print( + f"[yellow]→[/yellow] Found {len(job_dirs_in_scope)} job(s) in {scope_name} scope ({jobs_dir})" + ) + all_job_dirs.extend(job_dirs_in_scope) + + if not all_job_dirs: + console.print("[yellow]→[/yellow] No jobs found to sync") + else: + console.print( + f"[yellow]→[/yellow] Total: {len(all_job_dirs)} job(s) across all scopes" + ) # Parse all jobs jobs = [] failed_jobs: list[tuple[str, str]] = [] - for job_dir in job_dirs: + for job_dir in all_job_dirs: try: job_def = parse_job_definition(job_dir) jobs.append(job_def) @@ -109,10 +128,17 @@ def sync_skills(project_path: Path) -> None: console.print(f" • {job_name}: {error}") raise SyncError(f"Failed to parse {len(failed_jobs)} job(s)") - # Collect hooks from all jobs - job_hooks_list = collect_job_hooks(jobs_dir) - if job_hooks_list: - console.print(f"[yellow]→[/yellow] Found {len(job_hooks_list)} job(s) with hooks") + # Collect hooks from all job directories (both local and global) + from deepwork.core.hooks_syncer import JobHooks + + all_job_hooks: list[JobHooks] = [] + for _scope, jobs_dir in locations: + if jobs_dir.exists(): + hooks = collect_job_hooks(jobs_dir) + all_job_hooks.extend(hooks) + + if all_job_hooks: + console.print(f"[yellow]→[/yellow] Found {len(all_job_hooks)} job(s) with hooks") # Sync each platform generator = SkillGenerator() @@ -150,10 +176,10 @@ def sync_skills(project_path: Path) -> None: console.print(f" [red]✗[/red] Failed for {job.name}: {e}") # Sync hooks to platform settings - if job_hooks_list: + if all_job_hooks: console.print(" [dim]•[/dim] Syncing hooks...") try: - hooks_count = sync_hooks_to_platform(project_path, adapter, job_hooks_list) + hooks_count = sync_hooks_to_platform(project_path, adapter, all_job_hooks) stats["hooks"] += hooks_count if hooks_count > 0: console.print(f" [green]✓[/green] Synced {hooks_count} hook(s)") diff --git a/src/deepwork/standard_jobs/deepwork_jobs/make_new_job.sh b/src/deepwork/standard_jobs/deepwork_jobs/make_new_job.sh index c561d6d2..d3abf081 100755 --- a/src/deepwork/standard_jobs/deepwork_jobs/make_new_job.sh +++ b/src/deepwork/standard_jobs/deepwork_jobs/make_new_job.sh @@ -37,31 +37,58 @@ validate_job_name() { # Main script main() { if [[ $# -lt 1 ]]; then - echo "Usage: $0 " + echo "Usage: $0 [--scope local|global]" echo "" echo "Creates the directory structure for a new DeepWork job." echo "" echo "Arguments:" echo " job_name Name of the job (lowercase, underscores allowed)" + echo " --scope Installation scope: 'local' (project) or 'global' (XDG config)" + echo " Default: local" echo "" - echo "Example:" + echo "Examples:" echo " $0 competitive_research" + echo " $0 competitive_research --scope global" exit 1 fi local job_name="$1" + local scope="local" + + # Parse optional --scope argument + if [[ $# -ge 2 ]] && [[ "$2" == "--scope" ]]; then + if [[ $# -ge 3 ]]; then + scope="$3" + if [[ "$scope" != "local" && "$scope" != "global" ]]; then + error "Invalid scope '$scope'. Must be 'local' or 'global'." + fi + else + error "--scope requires a value (local or global)" + fi + fi + validate_job_name "$job_name" - # Determine the base path - look for .deepwork directory + # Determine the base path based on scope local base_path - if [[ -d ".deepwork/jobs" ]]; then - base_path=".deepwork/jobs" - elif [[ -d "../.deepwork/jobs" ]]; then - base_path="../.deepwork/jobs" - else - # Create from current directory - base_path=".deepwork/jobs" + if [[ "$scope" == "global" ]]; then + # Use XDG config directory for global jobs + local xdg_config="${XDG_CONFIG_HOME:-$HOME/.config}" + base_path="${xdg_config}/deepwork/jobs" mkdir -p "$base_path" + info "Creating global job in $base_path" + else + # Local scope - look for .deepwork directory + if [[ -d ".deepwork/jobs" ]]; then + base_path=".deepwork/jobs" + elif [[ -d "../.deepwork/jobs" ]]; then + base_path="../.deepwork/jobs" + else + # Create from current directory + base_path=".deepwork/jobs" + mkdir -p "$base_path" + fi + info "Creating local job in $base_path" fi local job_path="${base_path}/${job_name}" diff --git a/src/deepwork/standard_jobs/deepwork_jobs/steps/implement.md b/src/deepwork/standard_jobs/deepwork_jobs/steps/implement.md index 749c8c6f..a047cceb 100644 --- a/src/deepwork/standard_jobs/deepwork_jobs/steps/implement.md +++ b/src/deepwork/standard_jobs/deepwork_jobs/steps/implement.md @@ -8,31 +8,52 @@ Generate the DeepWork job directory structure and instruction files for each ste Read the `job.yml` specification file and create all the necessary files to make the job functional, including directory structure and step instruction files. Then sync the commands to make them available. -### Step 1: Create Directory Structure Using Script +### Step 1: Determine Installation Scope -Run the `make_new_job.sh` script to create the standard directory structure: +**Ask the user where they want to install this job using structured questions:** +Present two options: +1. **Local** - Install in this project only (`.deepwork/jobs/`) +2. **Global** - Install for all projects in your user config (`~/.config/deepwork/jobs/`) + +**Guidance for users:** +- Choose **local** if the job is specific to this project or you want to version control it +- Choose **global** if the job is reusable across multiple projects (e.g., a general "code review" or "documentation" workflow) + +### Step 2: Create Directory Structure Using Script + +Run the `make_new_job.sh` script to create the standard directory structure with the chosen scope: + +**For local installation:** ```bash -.deepwork/jobs/deepwork_jobs/make_new_job.sh [job_name] +.deepwork/jobs/deepwork_jobs/make_new_job.sh [job_name] --scope local ``` -This creates: -- `.deepwork/jobs/[job_name]/` - Main job directory -- `.deepwork/jobs/[job_name]/steps/` - Step instruction files -- `.deepwork/jobs/[job_name]/hooks/` - Custom validation scripts (with .gitkeep) -- `.deepwork/jobs/[job_name]/templates/` - Example file formats (with .gitkeep) -- `.deepwork/jobs/[job_name]/AGENTS.md` - Job management guidance - -**Note**: If the directory already exists (e.g., job.yml was created by define step), you can skip this step or manually create the additional directories: +**For global installation:** ```bash -mkdir -p .deepwork/jobs/[job_name]/hooks .deepwork/jobs/[job_name]/templates -touch .deepwork/jobs/[job_name]/hooks/.gitkeep .deepwork/jobs/[job_name]/templates/.gitkeep +.deepwork/jobs/deepwork_jobs/make_new_job.sh [job_name] --scope global ``` -### Step 2: Read and Validate the Specification +This creates: +- `[scope_path]/[job_name]/` - Main job directory +- `[scope_path]/[job_name]/steps/` - Step instruction files +- `[scope_path]/[job_name]/hooks/` - Custom validation scripts (with .gitkeep) +- `[scope_path]/[job_name]/templates/` - Example file formats (with .gitkeep) +- `[scope_path]/[job_name]/AGENTS.md` - Job management guidance + +Where `[scope_path]` is: +- `.deepwork/jobs` for local scope +- `~/.config/deepwork/jobs` (or `$XDG_CONFIG_HOME/deepwork/jobs`) for global scope + +**Note**: If the directory already exists (e.g., job.yml was created by define step), you can skip this step or manually create the additional directories in the appropriate scope location. + +### Step 3: Read and Validate the Specification 1. **Locate the job.yml file** - - Read `.deepwork/jobs/[job_name]/job.yml` from the review_job_spec step + - Read the job.yml from the review_job_spec step + - The file location depends on the chosen scope: + - Local: `.deepwork/jobs/[job_name]/job.yml` + - Global: `~/.config/deepwork/jobs/[job_name]/job.yml` (or `$XDG_CONFIG_HOME/deepwork/jobs/[job_name]/job.yml`) - Parse the YAML content 2. **Validate the specification** @@ -46,9 +67,11 @@ touch .deepwork/jobs/[job_name]/hooks/.gitkeep .deepwork/jobs/[job_name]/templat - List of all steps with their details - Understand the workflow structure -### Step 3: Generate Step Instruction Files +### Step 4: Generate Step Instruction Files -For each step in the job.yml, create a comprehensive instruction file at `.deepwork/jobs/[job_name]/steps/[step_id].md`. +For each step in the job.yml, create a comprehensive instruction file at the appropriate scope location: +- Local: `.deepwork/jobs/[job_name]/steps/[step_id].md` +- Global: `~/.config/deepwork/jobs/[job_name]/steps/[step_id].md` **Template reference**: See `.deepwork/jobs/deepwork_jobs/templates/step_instruction.md.template` for the standard structure. diff --git a/src/deepwork/utils/job_location.py b/src/deepwork/utils/job_location.py new file mode 100644 index 00000000..f152043b --- /dev/null +++ b/src/deepwork/utils/job_location.py @@ -0,0 +1,77 @@ +"""Job location and discovery utilities.""" + +from enum import Enum +from pathlib import Path + +from deepwork.utils.xdg import get_global_jobs_dir + + +class JobScope(Enum): + """Scope for job installation.""" + + LOCAL = "local" + GLOBAL = "global" + + +def get_jobs_dir(project_path: Path, scope: JobScope) -> Path: + """ + Get the jobs directory for the specified scope. + + Args: + project_path: Path to the project root (used for LOCAL scope) + scope: JobScope indicating where jobs should be stored + + Returns: + Path to the jobs directory + """ + if scope == JobScope.LOCAL: + return project_path / ".deepwork" / "jobs" + else: # JobScope.GLOBAL + return get_global_jobs_dir() + + +def discover_all_jobs_dirs(project_path: Path) -> list[tuple[JobScope, Path]]: + """ + Discover all available job directories (both local and global). + + Returns a list of tuples (scope, path) for all job directories that exist. + Local jobs are listed first, followed by global jobs. + + Args: + project_path: Path to the project root + + Returns: + List of (JobScope, Path) tuples for existing job directories + """ + result: list[tuple[JobScope, Path]] = [] + + # Check local jobs first + local_jobs_dir = get_jobs_dir(project_path, JobScope.LOCAL) + if local_jobs_dir.exists(): + result.append((JobScope.LOCAL, local_jobs_dir)) + + # Check global jobs + global_jobs_dir = get_jobs_dir(project_path, JobScope.GLOBAL) + if global_jobs_dir.exists(): + result.append((JobScope.GLOBAL, global_jobs_dir)) + + return result + + +def find_job(job_name: str, project_path: Path) -> tuple[JobScope, Path] | None: + """ + Find a job by name, searching local first, then global. + + Args: + job_name: Name of the job to find + project_path: Path to the project root + + Returns: + Tuple of (scope, job_path) if found, None otherwise + """ + for scope, jobs_dir in discover_all_jobs_dirs(project_path): + job_path = jobs_dir / job_name + if job_path.exists() and (job_path / "job.yml").exists(): + return (scope, job_path) + + return None diff --git a/src/deepwork/utils/xdg.py b/src/deepwork/utils/xdg.py new file mode 100644 index 00000000..155b8964 --- /dev/null +++ b/src/deepwork/utils/xdg.py @@ -0,0 +1,47 @@ +"""XDG Base Directory utilities for global job storage.""" + +import os +from pathlib import Path + + +def get_xdg_config_home() -> Path: + """ + Get the XDG config home directory. + + Returns the value of $XDG_CONFIG_HOME if set, otherwise defaults to + ~/.config according to XDG Base Directory specification. + + Returns: + Path to the XDG config home directory + """ + xdg_config = os.environ.get("XDG_CONFIG_HOME") + if xdg_config: + return Path(xdg_config) + return Path.home() / ".config" + + +def get_global_jobs_dir() -> Path: + """ + Get the global jobs directory for DeepWork. + + Global jobs are stored in $XDG_CONFIG_HOME/deepwork/jobs/ + (typically ~/.config/deepwork/jobs/) + + Returns: + Path to the global jobs directory + """ + return get_xdg_config_home() / "deepwork" / "jobs" + + +def ensure_global_jobs_dir() -> Path: + """ + Ensure the global jobs directory exists. + + Creates the directory if it doesn't exist and returns its path. + + Returns: + Path to the global jobs directory + """ + global_jobs_dir = get_global_jobs_dir() + global_jobs_dir.mkdir(parents=True, exist_ok=True) + return global_jobs_dir diff --git a/tests/integration/test_global_jobs.py b/tests/integration/test_global_jobs.py new file mode 100644 index 00000000..82c9d8cd --- /dev/null +++ b/tests/integration/test_global_jobs.py @@ -0,0 +1,220 @@ +"""Integration tests for global job support.""" + +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from deepwork.cli.main import cli +from deepwork.utils.xdg import get_global_jobs_dir + + +class TestGlobalJobSupport: + """Integration tests for global job storage and discovery.""" + + def test_sync_discovers_global_jobs( + self, mock_claude_project: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Test that sync command discovers jobs in global scope.""" + # Set up temporary global directory + global_config = tmp_path / "config" + monkeypatch.setenv("XDG_CONFIG_HOME", str(global_config)) + + # First, install DeepWork in the project + runner = CliRunner() + install_result = runner.invoke( + cli, ["install", "--path", str(mock_claude_project)], catch_exceptions=False + ) + assert install_result.exit_code == 0 + + # Create a global job + global_jobs_dir = get_global_jobs_dir() + global_jobs_dir.mkdir(parents=True, exist_ok=True) + + global_job_dir = global_jobs_dir / "global_test_job" + global_job_dir.mkdir(parents=True) + + # Create minimal job.yml + job_yml = global_job_dir / "job.yml" + job_yml.write_text( + """name: global_test_job +version: "1.0.0" +summary: "Global test job" +steps: + - id: step1 + name: "Step 1" + description: "Test step" + instructions_file: steps/step1.md + outputs: + - output.txt +""" + ) + + # Create steps directory + steps_dir = global_job_dir / "steps" + steps_dir.mkdir() + (steps_dir / "step1.md").write_text("# Test step instructions") + + # Run sync command + result = runner.invoke( + cli, ["sync", "--path", str(mock_claude_project)], catch_exceptions=False + ) + + assert result.exit_code == 0 + assert "Syncing DeepWork Skills" in result.output + # Should find jobs in both local and global scopes + assert "global_test_job" in result.output + assert "Loaded global_test_job v1.0.0" in result.output + + def test_sync_loads_both_local_and_global_jobs_with_same_name( + self, mock_claude_project: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Test that sync loads both local and global jobs even when they have the same name. + + Note: Both jobs are loaded because they are in physically different directories. + This is different from duplicate detection - DeepWork allows the same job name + in different scopes, treating them as separate jobs. + """ + # Set up temporary global directory + global_config = tmp_path / "config" + monkeypatch.setenv("XDG_CONFIG_HOME", str(global_config)) + + # First, install DeepWork in the project + runner = CliRunner() + install_result = runner.invoke( + cli, ["install", "--path", str(mock_claude_project)], catch_exceptions=False + ) + assert install_result.exit_code == 0 + + # Create same job in both local and global + job_content = """name: duplicate_job +version: "1.0.0" +summary: "Duplicate job" +steps: + - id: step1 + name: "Step 1" + description: "Test step" + instructions_file: steps/step1.md + outputs: + - output.txt +""" + + # Local version + local_jobs_dir = mock_claude_project / ".deepwork" / "jobs" + local_job_dir = local_jobs_dir / "duplicate_job" + local_job_dir.mkdir(parents=True) + (local_job_dir / "job.yml").write_text(job_content) + local_steps = local_job_dir / "steps" + local_steps.mkdir() + (local_steps / "step1.md").write_text("# Local version") + + # Global version + global_jobs_dir = get_global_jobs_dir() + global_jobs_dir.mkdir(parents=True, exist_ok=True) + global_job_dir = global_jobs_dir / "duplicate_job" + global_job_dir.mkdir(parents=True) + (global_job_dir / "job.yml").write_text(job_content) + global_steps = global_job_dir / "steps" + global_steps.mkdir() + (global_steps / "step1.md").write_text("# Global version") + + # Run sync command + result = runner.invoke( + cli, ["sync", "--path", str(mock_claude_project)], catch_exceptions=False + ) + + assert result.exit_code == 0 + # Both jobs should be loaded (they are in different physical directories) + output_lines = result.output.split("\n") + + # Check that we found jobs in both scopes + assert any("local scope" in line for line in output_lines) + assert any("global scope" in line for line in output_lines) + + # Should see duplicate_job loaded twice (once from each location) + duplicate_loaded_lines = [ + line for line in output_lines if "Loaded duplicate_job" in line + ] + assert len(duplicate_loaded_lines) == 2, "Should load duplicate_job from both locations" + + def test_global_jobs_dir_respects_xdg_config_home( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Test that global jobs directory respects XDG_CONFIG_HOME.""" + custom_config = tmp_path / "custom_config" + monkeypatch.setenv("XDG_CONFIG_HOME", str(custom_config)) + + global_jobs_dir = get_global_jobs_dir() + + assert global_jobs_dir == custom_config / "deepwork" / "jobs" + + def test_global_jobs_dir_defaults_to_home_config( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that global jobs directory defaults to ~/.config when XDG_CONFIG_HOME is not set.""" + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + global_jobs_dir = get_global_jobs_dir() + + assert global_jobs_dir == Path.home() / ".config" / "deepwork" / "jobs" + + def test_sync_with_only_global_jobs( + self, mock_claude_project: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Test sync when only global jobs exist (no local jobs).""" + # Set up temporary global directory + global_config = tmp_path / "config" + monkeypatch.setenv("XDG_CONFIG_HOME", str(global_config)) + + # First, install DeepWork in the project + runner = CliRunner() + install_result = runner.invoke( + cli, ["install", "--path", str(mock_claude_project)], catch_exceptions=False + ) + assert install_result.exit_code == 0 + + # Create a global job + global_jobs_dir = get_global_jobs_dir() + global_jobs_dir.mkdir(parents=True, exist_ok=True) + + global_job_dir = global_jobs_dir / "only_global_job" + global_job_dir.mkdir(parents=True) + + job_yml = global_job_dir / "job.yml" + job_yml.write_text( + """name: only_global_job +version: "1.0.0" +summary: "Only in global scope" +steps: + - id: step1 + name: "Step 1" + description: "Test step" + instructions_file: steps/step1.md + outputs: + - output.txt +""" + ) + + steps_dir = global_job_dir / "steps" + steps_dir.mkdir() + (steps_dir / "step1.md").write_text("# Global only step") + + # Remove all local jobs except standard jobs + local_jobs_dir = mock_claude_project / ".deepwork" / "jobs" + if local_jobs_dir.exists(): + for job_dir in local_jobs_dir.iterdir(): + if job_dir.is_dir() and job_dir.name not in [ + "deepwork_jobs", + "deepwork_rules", + ]: + import shutil + + shutil.rmtree(job_dir) + + result = runner.invoke( + cli, ["sync", "--path", str(mock_claude_project)], catch_exceptions=False + ) + + assert result.exit_code == 0 + assert "only_global_job" in result.output + assert "Loaded only_global_job v1.0.0" in result.output diff --git a/tests/integration/test_port_command.py b/tests/integration/test_port_command.py new file mode 100644 index 00000000..83ba2290 --- /dev/null +++ b/tests/integration/test_port_command.py @@ -0,0 +1,212 @@ +"""Integration tests for the port command.""" + +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from deepwork.cli.main import cli +from deepwork.utils.job_location import JobScope, get_jobs_dir +from deepwork.utils.xdg import get_global_jobs_dir + + +class TestPortCommand: + """Integration tests for 'deepwork port' command.""" + + def test_port_help(self) -> None: + """Test that port help works.""" + runner = CliRunner() + result = runner.invoke(cli, ["port", "--help"]) + + assert result.exit_code == 0 + assert "Port a DeepWork job between local and global scopes" in result.output + + def test_port_from_local_to_global( + self, mock_claude_project: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Test porting a job from local to global scope.""" + # Set up temporary global directory + global_config = tmp_path / "config" + monkeypatch.setenv("XDG_CONFIG_HOME", str(global_config)) + + # Create a test job in local scope + local_jobs_dir = mock_claude_project / ".deepwork" / "jobs" + test_job_dir = local_jobs_dir / "test_job" + test_job_dir.mkdir(parents=True) + + # Create minimal job.yml + job_yml = test_job_dir / "job.yml" + job_yml.write_text( + """name: test_job +version: "1.0.0" +summary: "Test job for porting" +steps: + - id: step1 + name: "Step 1" + description: "Test step" + instructions_file: steps/step1.md + outputs: + - output.txt +""" + ) + + # Create steps directory + steps_dir = test_job_dir / "steps" + steps_dir.mkdir() + (steps_dir / "step1.md").write_text("# Test step instructions") + + runner = CliRunner() + + # Run port command with input simulation + # Input: 1 (local), 1 (first job), 2 (global destination) + result = runner.invoke( + cli, + ["port", "--path", str(mock_claude_project)], + input="1\n1\n2\n", + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "DeepWork Job Porting" in result.output + assert "test_job" in result.output + assert "Successfully ported" in result.output + + # Verify job was copied to global location + global_jobs_dir = get_global_jobs_dir() + global_test_job = global_jobs_dir / "test_job" + assert global_test_job.exists() + assert (global_test_job / "job.yml").exists() + assert (global_test_job / "steps" / "step1.md").exists() + + def test_port_from_global_to_local( + self, mock_claude_project: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Test porting a job from global to local scope.""" + # Set up temporary global directory + global_config = tmp_path / "config" + monkeypatch.setenv("XDG_CONFIG_HOME", str(global_config)) + + # Create a test job in global scope + global_jobs_dir = get_global_jobs_dir() + global_jobs_dir.mkdir(parents=True, exist_ok=True) + + test_job_dir = global_jobs_dir / "global_test_job" + test_job_dir.mkdir(parents=True) + + # Create minimal job.yml + job_yml = test_job_dir / "job.yml" + job_yml.write_text( + """name: global_test_job +version: "1.0.0" +summary: "Global test job for porting" +steps: + - id: step1 + name: "Step 1" + description: "Test step" + instructions_file: steps/step1.md + outputs: + - output.txt +""" + ) + + # Create steps directory + steps_dir = test_job_dir / "steps" + steps_dir.mkdir() + (steps_dir / "step1.md").write_text("# Global test step instructions") + + runner = CliRunner() + + # Run port command with input simulation + # Input: 2 (global), 1 (first job), 1 (local destination) + result = runner.invoke( + cli, + ["port", "--path", str(mock_claude_project)], + input="2\n1\n1\n", + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "global_test_job" in result.output + assert "Successfully ported" in result.output + + # Verify job was copied to local location + local_jobs_dir = get_jobs_dir(mock_claude_project, JobScope.LOCAL) + local_test_job = local_jobs_dir / "global_test_job" + assert local_test_job.exists() + assert (local_test_job / "job.yml").exists() + assert (local_test_job / "steps" / "step1.md").exists() + + def test_port_overwrite_existing( + self, mock_claude_project: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Test that port asks for confirmation when overwriting existing job.""" + # Set up temporary global directory + global_config = tmp_path / "config" + monkeypatch.setenv("XDG_CONFIG_HOME", str(global_config)) + + # Create test job in local scope + local_jobs_dir = mock_claude_project / ".deepwork" / "jobs" + test_job_dir = local_jobs_dir / "test_job" + test_job_dir.mkdir(parents=True) + + job_yml = test_job_dir / "job.yml" + job_yml.write_text( + """name: test_job +version: "1.0.0" +summary: "Test job" +steps: + - id: step1 + name: "Step 1" + description: "Test step" + instructions_file: steps/step1.md + outputs: + - output.txt +""" + ) + + (test_job_dir / "steps").mkdir() + (test_job_dir / "steps" / "step1.md").write_text("# Original") + + # Create same job in global scope + global_jobs_dir = get_global_jobs_dir() + global_jobs_dir.mkdir(parents=True, exist_ok=True) + global_test_job = global_jobs_dir / "test_job" + global_test_job.mkdir(parents=True) + (global_test_job / "job.yml").write_text("name: test_job\nversion: 1.0.0") + + runner = CliRunner() + + # Run port command and decline overwrite + # Input: 1 (local), 1 (first job), 2 (global), n (don't overwrite) + result = runner.invoke( + cli, + ["port", "--path", str(mock_claude_project)], + input="1\n1\n2\nn\n", + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "already exists" in result.output + assert "Porting cancelled" in result.output + + def test_port_with_no_jobs_in_source( + self, mock_claude_project: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Test that port fails gracefully when no jobs exist in source.""" + # Set up temporary global directory with no jobs + global_config = tmp_path / "config" + monkeypatch.setenv("XDG_CONFIG_HOME", str(global_config)) + + global_jobs_dir = get_global_jobs_dir() + global_jobs_dir.mkdir(parents=True, exist_ok=True) + + runner = CliRunner() + + # Try to port from empty global directory + # Input: 2 (global) + result = runner.invoke( + cli, ["port", "--path", str(mock_claude_project)], input="2\n" + ) + + assert result.exit_code != 0 + assert "No jobs found" in result.output diff --git a/tests/shell_script_tests/test_make_new_job.py b/tests/shell_script_tests/test_make_new_job.py index 913d66ea..96b9d293 100644 --- a/tests/shell_script_tests/test_make_new_job.py +++ b/tests/shell_script_tests/test_make_new_job.py @@ -58,7 +58,7 @@ def test_shows_example_in_usage(self, jobs_scripts_dir: Path, project_dir: Path) script_path = jobs_scripts_dir / "make_new_job.sh" stdout, stderr, code = run_make_new_job(script_path, project_dir) - assert "Example:" in stdout, "Should show example usage" + assert "Examples:" in stdout, "Should show example usage" class TestMakeNewJobNameValidation: diff --git a/tests/unit/test_xdg.py b/tests/unit/test_xdg.py new file mode 100644 index 00000000..909543f0 --- /dev/null +++ b/tests/unit/test_xdg.py @@ -0,0 +1,69 @@ +"""Unit tests for XDG utilities.""" + +import os +from pathlib import Path + +import pytest + +from deepwork.utils.xdg import ( + ensure_global_jobs_dir, + get_global_jobs_dir, + get_xdg_config_home, +) + + +class TestXDGUtilities: + """Test XDG Base Directory utilities.""" + + def test_get_xdg_config_home_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test get_xdg_config_home returns $XDG_CONFIG_HOME when set.""" + test_path = "/custom/config" + monkeypatch.setenv("XDG_CONFIG_HOME", test_path) + + result = get_xdg_config_home() + + assert result == Path(test_path) + + def test_get_xdg_config_home_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test get_xdg_config_home returns ~/.config by default.""" + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + result = get_xdg_config_home() + + assert result == Path.home() / ".config" + + def test_get_global_jobs_dir(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test get_global_jobs_dir returns correct path.""" + test_config = "/test/config" + monkeypatch.setenv("XDG_CONFIG_HOME", test_config) + + result = get_global_jobs_dir() + + assert result == Path(test_config) / "deepwork" / "jobs" + + def test_ensure_global_jobs_dir_creates_directory( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Test ensure_global_jobs_dir creates the directory if it doesn't exist.""" + test_config = tmp_path / "config" + monkeypatch.setenv("XDG_CONFIG_HOME", str(test_config)) + + result = ensure_global_jobs_dir() + + assert result.exists() + assert result.is_dir() + assert result == test_config / "deepwork" / "jobs" + + def test_ensure_global_jobs_dir_idempotent( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Test ensure_global_jobs_dir is idempotent.""" + test_config = tmp_path / "config" + monkeypatch.setenv("XDG_CONFIG_HOME", str(test_config)) + + # Call twice + result1 = ensure_global_jobs_dir() + result2 = ensure_global_jobs_dir() + + assert result1 == result2 + assert result1.exists()