Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Jan 30, 2026

Adds deepwork jobs command group enabling discovery and import of jobs from multiple sources: the default library, local filesystem paths, or GitHub repositories.

Commands

deepwork jobs list [SOURCE]

Lists available jobs with name, version, and summary. Sources:

  • Default: library/jobs in deepwork package
  • Local path: /path/to/repo or /path/to/jobs
  • GitHub URL: https://github.com/owner/repo

Job Discovery Priority:
When pointing to a repository, the tool searches for jobs in the following order:

  1. .deepwork/jobs - Jobs in deepwork-enabled repositories
  2. library/jobs - Library jobs (e.g., in the deepwork repo itself)
  3. jobs - Generic jobs directory at the root

deepwork jobs clone JOB_NAME [SOURCE]

Copies job to .deepwork/jobs/ with validation and automatic skill sync. Prompts before overwriting existing jobs.

Implementation

New file: src/deepwork/cli/jobs.py

  • _resolve_source(): Resolves source to local path, cloning GitHub repos to temp directories with proper cleanup. Checks for jobs in .deepwork/jobs, library/jobs, or jobs directories in priority order.
  • _discover_jobs(): Discovers job directories containing job.yml
  • list_jobs(): Renders job table using rich
  • clone(): Copies job structure, fixes permissions, runs deepwork sync

Error handling:

  • Uses specific exceptions (ParseError, SyncError, GitCommandError)
  • Strict GitHub URL validation (exact domain match: github.com or *.github.com)
  • Temp directory cleanup on all failure paths

Example Usage

# List jobs from default library
$ deepwork jobs list

# Clone commit workflow
$ deepwork jobs clone commit

# Import from GitHub (works with deepwork-enabled repos)
$ deepwork jobs list https://github.com/org/workflows
$ deepwork jobs clone my-workflow https://github.com/org/workflows

# List jobs from a deepwork-enabled repository
$ deepwork jobs list https://github.com/user/deepwork-notes

Note: The spec_driven_development job in library has a schema validation error (uses hidden field which is not allowed). This is a pre-existing issue unrelated to these changes.

Original prompt

Enable the deepwork CLI to list or clone jobs from another repo defaulting to deepwork by default to library/jobs in this repo. This can take the URL or path to another repo and list it's available jobs. After listing a job we can then clone it

Demo

Listing jobs

image

Cloning a Job

image

Git diff after cloniing a job

image

Copilot AI and others added 3 commits January 30, 2026 12:57
Co-authored-by: ncrmro <8276365+ncrmro@users.noreply.github.com>
Co-authored-by: ncrmro <8276365+ncrmro@users.noreply.github.com>
Co-authored-by: ncrmro <8276365+ncrmro@users.noreply.github.com>
Copilot AI changed the title [WIP] Enable deepwork CLI to list or clone jobs from another repo Add CLI commands to list and clone jobs from repositories Jan 30, 2026
Copilot AI requested a review from ncrmro January 30, 2026 13:06
@ncrmro
Copy link
Collaborator

ncrmro commented Jan 30, 2026

@copilot this should look for jobs in that repo in the .deepwork folder eg jobs inside of that repo or path to repo.

❯ deepwork jobs list https://github.com/ncrmro/deepwork-notes

Available Jobs

→ Cloning repository from https://github.com/ncrmro/deepwork-notes...
  ✓ Repository cloned
Error: Could not find jobs in cloned repository. Expected 'library/jobs' or 'jobs' directory.
Aborted!

Co-authored-by: ncrmro <8276365+ncrmro@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Jan 30, 2026

@copilot this should look for jobs in that repo in the .deepwork folder eg jobs inside of that repo or path to repo.

...

Fixed in commit 4410128. The tool now checks for jobs in the following priority order:

  1. .deepwork/jobs - for deepwork-enabled repositories
  2. library/jobs - for library directories
  3. jobs - for generic job directories

This means deepwork jobs list https://github.com/ncrmro/deepwork-notes will now find jobs in the .deepwork/jobs directory of that repository.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds job management capabilities to the DeepWork CLI, enabling users to discover and import jobs from multiple sources including the default library, local filesystem paths, and GitHub repositories.

Changes:

  • Adds deepwork jobs list command to display available jobs with name, version, and summary information
  • Adds deepwork jobs clone command to copy jobs into a project's .deepwork/jobs/ directory with validation and automatic skill synchronization
  • Implements flexible source resolution with support for default library, local paths, and GitHub URLs with automatic repository cloning

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.

File Description
src/deepwork/cli/jobs.py New file implementing the jobs command group with list and clone subcommands, source resolution, job discovery, and GitHub repository cloning
src/deepwork/cli/main.py Registers the new jobs command group with the CLI
tests/integration/test_jobs_command.py Comprehensive integration tests covering list/clone operations for default library, local paths, error cases, and edge conditions

console.print(f"[yellow]→[/yellow] Cloning repository from {source}...")
tmp_dir = Path(tempfile.mkdtemp(prefix="deepwork_jobs_"))
try:
from git import GitCommandError
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitCommandError is imported locally inside the try block, while Repo is imported at the module level (line 9). This is inconsistent and unnecessary. Both imports come from the same git package and should be imported together at the module level for consistency.

Move the GitCommandError import to line 9 to match the existing import style.

Copilot uses AI. Check for mistakes.
Comment on lines +100 to +136
if _is_github_url(source):
# Clone to temporary directory
console.print(f"[yellow]→[/yellow] Cloning repository from {source}...")
tmp_dir = Path(tempfile.mkdtemp(prefix="deepwork_jobs_"))
try:
from git import GitCommandError

Repo.clone_from(source, tmp_dir, depth=1)
console.print(" [green]✓[/green] Repository cloned")

# Look for jobs in standard locations (in order of priority)
if (tmp_dir / ".deepwork" / "jobs").exists():
return tmp_dir / ".deepwork" / "jobs"

if (tmp_dir / "library" / "jobs").exists():
return tmp_dir / "library" / "jobs"

# Look for a jobs directory at the root
if (tmp_dir / "jobs").exists():
return tmp_dir / "jobs"

# Clean up if jobs not found
shutil.rmtree(tmp_dir, ignore_errors=True)
raise JobsError(
"Could not find jobs in cloned repository. "
"Expected '.deepwork/jobs', 'library/jobs', or 'jobs' directory."
)
except GitCommandError as e:
shutil.rmtree(tmp_dir, ignore_errors=True)
raise JobsError(f"Failed to clone repository: {e}") from e
except JobsError:
# Re-raise our own errors
raise
except Exception as e:
# Cleanup and wrap any other unexpected errors
shutil.rmtree(tmp_dir, ignore_errors=True)
raise JobsError(f"Unexpected error accessing repository: {e}") from e
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The temporary directory created for cloned repositories is never cleaned up after use. When cloning from a GitHub URL, a temp directory is created and a path inside it is returned. This temp directory persists on disk even after the command completes, leading to a resource leak.

The issue affects both the list and clone commands when using GitHub URLs. The temp directories will accumulate in the system's temp folder over time.

Consider using a context manager or storing the temp directory reference so it can be cleaned up after the command completes. One approach would be to use tempfile.TemporaryDirectory() as a context manager at the command level, or implement a cleanup mechanism that tracks and removes the temp directory after the jobs have been copied.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +317
"""Integration tests for the jobs command."""

import shutil
from pathlib import Path

from click.testing import CliRunner

from deepwork.cli.main import cli


class TestJobsCommand:
"""Integration tests for 'deepwork jobs' command."""

def test_jobs_list_default(self) -> None:
"""Test listing jobs from default library."""
runner = CliRunner()

result = runner.invoke(
cli,
["jobs", "list"],
catch_exceptions=False,
)

assert result.exit_code == 0
assert "Available Jobs" in result.output
assert "deepwork library/jobs (default)" in result.output
# The commit job should always be in the library
assert "commit" in result.output
assert "To clone a job" in result.output

def test_jobs_list_local_path(self, tmp_path: Path) -> None:
"""Test listing jobs from a local path."""
runner = CliRunner()

# Create a test jobs directory
jobs_dir = tmp_path / "test_repo" / "library" / "jobs"
jobs_dir.mkdir(parents=True)

# Create a test job
test_job_dir = jobs_dir / "test_job"
test_job_dir.mkdir()
(test_job_dir / "job.yml").write_text(
"""name: test_job
version: "1.0.0"
summary: "A test job for integration testing"
description: |
This is a test job.

steps:
- id: test_step
name: "Test Step"
description: "A test step"
instructions_file: steps/test.md
inputs: []
outputs: []
dependencies: []
quality_criteria:
- "Test passed"
"""
)

# Create steps directory
(test_job_dir / "steps").mkdir()
(test_job_dir / "steps" / "test.md").write_text("# Test Step\n\nTest instructions.")

# List jobs from local path
result = runner.invoke(
cli,
["jobs", "list", str(tmp_path / "test_repo")],
catch_exceptions=False,
)

assert result.exit_code == 0
assert "Available Jobs" in result.output
assert "test_job" in result.output
assert "1.0.0" in result.output
assert "A test job for integration testing" in result.output

def test_jobs_list_from_deepwork_jobs_directory(self, tmp_path: Path) -> None:
"""Test listing jobs from a .deepwork/jobs directory."""
runner = CliRunner()

# Create a test .deepwork/jobs directory
jobs_dir = tmp_path / "test_repo" / ".deepwork" / "jobs"
jobs_dir.mkdir(parents=True)

# Create a test job
test_job_dir = jobs_dir / "deepwork_test_job"
test_job_dir.mkdir()
(test_job_dir / "job.yml").write_text(
"""name: deepwork_test_job
version: "2.0.0"
summary: "A test job from .deepwork/jobs"
description: |
This is a test job from .deepwork/jobs.

steps:
- id: test_step
name: "Test Step"
description: "A test step"
instructions_file: steps/test.md
inputs: []
outputs: []
dependencies: []
quality_criteria:
- "Test passed"
"""
)

# Create steps directory
(test_job_dir / "steps").mkdir()
(test_job_dir / "steps" / "test.md").write_text("# Test Step\n\nTest instructions.")

# List jobs from local path
result = runner.invoke(
cli,
["jobs", "list", str(tmp_path / "test_repo")],
catch_exceptions=False,
)

assert result.exit_code == 0
assert "Available Jobs" in result.output
assert "deepwork_test_job" in result.output
assert "2.0.0" in result.output
assert "A test job from .deepwork/jobs" in result.output

def test_jobs_list_nonexistent_path(self) -> None:
"""Test listing jobs from a nonexistent path."""
runner = CliRunner()

result = runner.invoke(
cli,
["jobs", "list", "/nonexistent/path"],
catch_exceptions=False,
)

assert result.exit_code == 1
assert "Error:" in result.output
assert "Source not found" in result.output

def test_jobs_clone_from_default(self, mock_claude_project: Path) -> None:
"""Test cloning a job from the default library."""
runner = CliRunner()

# Install DeepWork first
result = runner.invoke(
cli,
["install", "--platform", "claude", "--path", str(mock_claude_project)],
catch_exceptions=False,
)
assert result.exit_code == 0

# Clone the commit job
result = runner.invoke(
cli,
["jobs", "clone", "commit", "--path", str(mock_claude_project)],
catch_exceptions=False,
)

assert result.exit_code == 0
assert "Cloning Job" in result.output
assert "Using deepwork library/jobs (default)" in result.output
assert "Found job 'commit'" in result.output
assert "Validating job definition" in result.output
assert "commit v1.0.0" in result.output
assert "Copying job to project" in result.output
assert "Job 'commit' cloned successfully" in result.output

# Verify job was copied
cloned_job_path = mock_claude_project / ".deepwork" / "jobs" / "commit"
assert cloned_job_path.exists()
assert (cloned_job_path / "job.yml").exists()
assert (cloned_job_path / "steps").exists()

def test_jobs_clone_from_local_path(self, mock_claude_project: Path, tmp_path: Path) -> None:
"""Test cloning a job from a local path."""
runner = CliRunner()

# Install DeepWork first
result = runner.invoke(
cli,
["install", "--platform", "claude", "--path", str(mock_claude_project)],
catch_exceptions=False,
)
assert result.exit_code == 0

# Create a test jobs directory
jobs_dir = tmp_path / "test_repo" / "library" / "jobs"
jobs_dir.mkdir(parents=True)

# Create a test job
test_job_dir = jobs_dir / "my_custom_job"
test_job_dir.mkdir()
(test_job_dir / "job.yml").write_text(
"""name: my_custom_job
version: "2.0.0"
summary: "A custom test job"
description: |
This is a custom test job.

steps:
- id: custom_step
name: "Custom Step"
description: "A custom step"
instructions_file: steps/custom.md
inputs: []
outputs: []
dependencies: []
quality_criteria:
- "Custom test passed"
"""
)

# Create steps directory
(test_job_dir / "steps").mkdir()
(test_job_dir / "steps" / "custom.md").write_text("# Custom Step\n\nCustom instructions.")

# Clone the job from local path
result = runner.invoke(
cli,
[
"jobs",
"clone",
"my_custom_job",
str(tmp_path / "test_repo"),
"--path",
str(mock_claude_project),
],
catch_exceptions=False,
)

assert result.exit_code == 0
assert "Cloning Job" in result.output
assert "Found job 'my_custom_job'" in result.output
assert "my_custom_job v2.0.0" in result.output
assert "Job 'my_custom_job' cloned successfully" in result.output

# Verify job was copied
cloned_job_path = mock_claude_project / ".deepwork" / "jobs" / "my_custom_job"
assert cloned_job_path.exists()
assert (cloned_job_path / "job.yml").exists()
assert (cloned_job_path / "steps").exists()
assert (cloned_job_path / "steps" / "custom.md").exists()

def test_jobs_clone_nonexistent_job(self, mock_claude_project: Path) -> None:
"""Test cloning a nonexistent job."""
runner = CliRunner()

# Install DeepWork first
result = runner.invoke(
cli,
["install", "--platform", "claude", "--path", str(mock_claude_project)],
catch_exceptions=False,
)
assert result.exit_code == 0

# Try to clone a nonexistent job
result = runner.invoke(
cli,
["jobs", "clone", "nonexistent_job", "--path", str(mock_claude_project)],
catch_exceptions=False,
)

assert result.exit_code == 1
assert "Error:" in result.output
assert "not found in source" in result.output

def test_jobs_clone_overwrite_existing(self, mock_claude_project: Path) -> None:
"""Test cloning a job that already exists."""
runner = CliRunner()

# Install DeepWork first
result = runner.invoke(
cli,
["install", "--platform", "claude", "--path", str(mock_claude_project)],
catch_exceptions=False,
)
assert result.exit_code == 0

# Clone the commit job first time
result = runner.invoke(
cli,
["jobs", "clone", "commit", "--path", str(mock_claude_project)],
catch_exceptions=False,
)
assert result.exit_code == 0

# Try to clone again (without confirming overwrite)
result = runner.invoke(
cli,
["jobs", "clone", "commit", "--path", str(mock_claude_project)],
input="n\n", # Say no to overwrite
catch_exceptions=False,
)

assert result.exit_code == 0
assert "already exists" in result.output
assert "Clone cancelled" in result.output

def test_jobs_clone_without_deepwork_installed(self, tmp_path: Path) -> None:
"""Test cloning a job without DeepWork installed."""
runner = CliRunner()

# Create an empty project directory without DeepWork
project_dir = tmp_path / "empty_project"
project_dir.mkdir()

# Try to clone a job
result = runner.invoke(
cli,
["jobs", "clone", "commit", "--path", str(project_dir)],
catch_exceptions=False,
)

assert result.exit_code == 1
assert "Error:" in result.output
assert "DeepWork not initialized" in result.output
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test suite is missing coverage for GitHub URL functionality. There are no tests that exercise the GitHub URL cloning path, which means the following code paths are untested:

  1. Valid GitHub URLs (github.com and *.github.com)
  2. Invalid GitHub URLs that fail _is_github_url validation
  3. GitHub clone failures (GitCommandError handling)
  4. Repository cloning with missing jobs directories
  5. Job discovery priority order in cloned repos (.deepwork/jobs > library/jobs > jobs)

Since other test files in the repository include comprehensive test coverage, tests should be added for these GitHub URL scenarios to match the testing standards of the codebase.

Copilot uses AI. Check for mistakes.
if (source_path / ".deepwork" / "jobs").exists():
return source_path / ".deepwork" / "jobs"
if (source_path / "library" / "jobs").exists():
return source_path / "library" / "jobs"
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent job discovery behavior between local paths and GitHub URLs. When a local path is provided, the code checks for .deepwork/jobs and library/jobs, but if neither exists, it assumes the provided path itself is the jobs directory (line 96). However, for GitHub URLs (lines 111-119), the code additionally checks for a jobs directory at the repository root before falling back to an error.

This means if a repository has jobs in a jobs/ directory at the root (rather than .deepwork/jobs or library/jobs), it will be discovered when cloning from GitHub but not when pointing to a local clone of that same repository.

For consistency, consider adding a check for source_path / "jobs" in the local path handling (around line 94-95) before falling back to returning the path itself.

Suggested change
return source_path / "library" / "jobs"
return source_path / "library" / "jobs"
if (source_path / "jobs").exists():
return source_path / "jobs"

Copilot uses AI. Check for mistakes.
if library_path.exists():
return library_path

# Fallback: try to clone from GitHub
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misleading comment: The comment states "Fallback: try to clone from GitHub" but the code immediately raises a JobsError without attempting any GitHub cloning. The comment should be removed or updated to accurately reflect that this is an error path when the local library cannot be found.

Suggested change
# Fallback: try to clone from GitHub
# If not found, require the caller to provide an explicit source path or URL

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,317 @@
"""Integration tests for the jobs command."""

import shutil
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'shutil' is not used.

Suggested change
import shutil

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants