From 91b1ca3b29d5ecc3134eebd7b360af2252459cd0 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 17 Jan 2026 16:20:31 +0000 Subject: [PATCH 01/10] refactor: Change CLI to workspace-first syntax New syntax puts workspace before commands for better autocomplete: - `dl stop` instead of `dl --stop ` - `dl code` instead of `dl --code ` - `dl restart` (reset), `recreate`, `rm`, `status` - `dl -- ` to run shell commands Global commands remain: `--ls`, `--install`, `--help` Co-Authored-By: Claude Opus 4.5 --- devlaunch/completions/dl.bash | 38 ++++--- devlaunch/dl.py | 185 +++++++++++++++------------------- 2 files changed, 102 insertions(+), 121 deletions(-) diff --git a/devlaunch/completions/dl.bash b/devlaunch/completions/dl.bash index 507b596e..8e381db8 100644 --- a/devlaunch/completions/dl.bash +++ b/devlaunch/completions/dl.bash @@ -5,14 +5,11 @@ _dl_completion() { cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - # Command options - opts="--ls --repos --stop --rm --code --status --recreate --reset --install --help" + # Global command options (only valid as first arg) + local global_opts="--ls --install --help" - # Flag completion - if [[ ${cur} == -* ]]; then - COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) - return 0 - fi + # Workspace subcommands + local ws_cmds="stop rm code status restart recreate --" # Cache file location (honors XDG_CACHE_HOME) local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/dl" @@ -28,16 +25,14 @@ _dl_completion() { source "$cache_file" fi - # Commands that need workspace completion - if [[ "$prev" == "--stop" || "$prev" == "--rm" || "$prev" == "--code" || "$prev" == "--status" || "$prev" == "--recreate" || "$prev" == "--reset" ]]; then - if [[ -n "$DL_WORKSPACES" ]]; then - COMPREPLY=( $(compgen -W "${DL_WORKSPACES}" -- ${cur}) ) + # First argument: global flags, workspaces, repos, owners, or paths + if [[ ${COMP_CWORD} -eq 1 ]]; then + # Global flags + if [[ ${cur} == -* ]]; then + COMPREPLY=( $(compgen -W "${global_opts}" -- ${cur}) ) + return 0 fi - return 0 - fi - # First positional argument: workspace, owner/repo, or path - if [[ ${COMP_CWORD} -eq 1 ]]; then # Don't add space after completion to allow @branch suffix compopt -o nospace @@ -71,6 +66,19 @@ _dl_completion() { return 0 fi + # Second argument (after workspace): subcommands + if [[ ${COMP_CWORD} -eq 2 ]]; then + # Don't complete after global flags + local first="${COMP_WORDS[1]}" + if [[ "$first" == --* ]]; then + return 0 + fi + + COMPREPLY=( $(compgen -W "${ws_cmds}" -- ${cur}) ) + return 0 + fi + + # After "--": no completion (user types shell command) return 0 } diff --git a/devlaunch/dl.py b/devlaunch/dl.py index 4bb8306f..90221710 100644 --- a/devlaunch/dl.py +++ b/devlaunch/dl.py @@ -487,21 +487,28 @@ def print_help(): Usage: dl Interactive workspace selector (fzf) dl Start workspace and attach shell - dl Run command in workspace - dl owner/repo Create workspace from GitHub repo - dl owner/repo@branch Create workspace from specific branch - dl ./path Create workspace from local path - -Commands: - --ls List all workspaces - --stop Stop a workspace - --rm Delete a workspace - --code Open workspace in VS Code - --status Show workspace status - --recreate Recreate workspace container - --reset Reset workspace (clean slate) - --install Install shell completions - --help, -h Show this help + dl Run workspace command (stop, code, etc.) + dl -- Run shell command in workspace + +Workspace sources: + dl myproject Existing workspace by name + dl owner/repo Create from GitHub repo + dl owner/repo@branch Create from specific branch + dl ./path Create from local path + +Workspace commands: + dl stop Stop the workspace + dl rm Delete the workspace + dl code Open in VS Code + dl status Show workspace status + dl restart Reset workspace (clean slate) + dl recreate Recreate workspace container + dl -- Run shell command in workspace + +Global commands: + dl --ls List all workspaces + dl --install Install shell completions + dl --help, -h Show this help Examples: dl # Select workspace with fzf @@ -509,8 +516,9 @@ def print_help(): dl loft-sh/devpod # Create from GitHub dl blooop/devlaunch@main # Create from specific branch dl ./my-project # Create from local folder - dl --code myproject # Open in VS Code - dl myproject 'make test' # Run command in workspace + dl myproject code # Open in VS Code + dl myproject -- make test # Run command in workspace + dl myproject stop # Stop workspace """ print(help_text) @@ -519,20 +527,20 @@ def main() -> int: """Main entry point for dl CLI.""" args = sys.argv[1:] - # Handle help - if not args or (len(args) == 1 and args[0] in ("--help", "-h")): - if not args: - # No args - try fzf selection - selected = fuzzy_select_workspace() - if not selected: - print_help() - return 1 - workspace_up(selected) - return workspace_ssh(selected) + # No args - try fzf selection + if not args: + selected = fuzzy_select_workspace() + if not selected: + print_help() + return 1 + workspace_up(selected) + return workspace_ssh(selected) + + # Global commands (no workspace required) + if args[0] in ("--help", "-h"): print_help() return 0 - # Handle flags if args[0] == "--ls": print_workspaces() return 0 @@ -572,80 +580,9 @@ def main() -> int: update_completion_cache() return install_completions(rc_path) - if args[0] == "--stop": - if len(args) < 2: - workspace = fuzzy_select_workspace() - if not workspace: - logging.error("Usage: dl --stop ") - return 1 - else: - workspace = args[1] - return workspace_stop(workspace) - - if args[0] == "--rm": - if len(args) < 2: - workspace = fuzzy_select_workspace() - if not workspace: - logging.error("Usage: dl --rm ") - return 1 - else: - workspace = args[1] - return workspace_delete(workspace) - - if args[0] == "--status": - if len(args) < 2: - workspace = fuzzy_select_workspace() - if not workspace: - logging.error("Usage: dl --status ") - return 1 - else: - workspace = args[1] - return workspace_status(workspace) - - if args[0] == "--code": - if len(args) < 2: - workspace = fuzzy_select_workspace() - if not workspace: - logging.error("Usage: dl --code ") - return 1 - else: - workspace = args[1] - result = workspace_up(workspace, ide="vscode") - return result.returncode - - if args[0] == "--recreate": - if len(args) < 2: - workspace = fuzzy_select_workspace() - if not workspace: - logging.error("Usage: dl --recreate ") - return 1 - else: - workspace = args[1] - workspace_spec = expand_workspace_spec(workspace) - workspace_id = spec_to_workspace_id(workspace) - result = workspace_up(workspace_spec, recreate=True) - if result.returncode != 0: - return result.returncode - return workspace_ssh(workspace_id) - - if args[0] == "--reset": - if len(args) < 2: - workspace = fuzzy_select_workspace() - if not workspace: - logging.error("Usage: dl --reset ") - return 1 - else: - workspace = args[1] - workspace_spec = expand_workspace_spec(workspace) - workspace_id = spec_to_workspace_id(workspace) - result = workspace_up(workspace_spec, reset=True) - if result.returncode != 0: - return result.returncode - return workspace_ssh(workspace_id) - - # Default: workspace name and optional command + # Workspace commands: dl [subcommand] [-- command] raw_spec = args[0] - command = " ".join(args[1:]) if len(args) > 1 else None + subcommand = args[1] if len(args) > 1 else None # Validate the workspace spec existing_ids = get_workspace_ids() @@ -654,8 +591,7 @@ def main() -> int: logging.error(error) return 1 - # Use raw spec as-is if it's an existing workspace ID, otherwise expand - # This prevents owner/repo-style workspace IDs from being rewritten + # Resolve workspace spec and ID if raw_spec in existing_ids: workspace_spec = raw_spec workspace_id = raw_spec @@ -663,13 +599,50 @@ def main() -> int: workspace_spec = expand_workspace_spec(raw_spec) workspace_id = spec_to_workspace_id(raw_spec) - # Start the workspace + # Handle workspace subcommands + if subcommand == "stop": + return workspace_stop(workspace_id) + + if subcommand in ("rm", "prune"): + return workspace_delete(workspace_id) + + if subcommand == "status": + return workspace_status(workspace_id) + + if subcommand == "code": + result = workspace_up(workspace_spec, ide="vscode") + return result.returncode + + if subcommand == "recreate": + result = workspace_up(workspace_spec, recreate=True) + if result.returncode != 0: + return result.returncode + return workspace_ssh(workspace_id) + + if subcommand == "restart": + result = workspace_up(workspace_spec, reset=True) + if result.returncode != 0: + return result.returncode + return workspace_ssh(workspace_id) + + # Check for shell command (after --) + shell_command = None + if subcommand == "--" and len(args) > 2: + shell_command = " ".join(args[2:]) + elif subcommand is not None and subcommand != "--": + # Unknown subcommand - treat as error + logging.error( + f"Unknown command '{subcommand}'. Use 'dl {raw_spec} -- {subcommand}' to run a shell command." + ) + return 1 + + # Default: start workspace and attach shell result = workspace_up(workspace_spec) if result.returncode != 0: return result.returncode - # Attach to workspace using the ID (not the full spec) - ret = workspace_ssh(workspace_id, command) + # Attach to workspace + ret = workspace_ssh(workspace_id, shell_command) # Update cache in background after workspace operations update_cache_background() From 6f40f136f066e473ed6366f384d17c24ccf04747 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 17 Jan 2026 16:22:12 +0000 Subject: [PATCH 02/10] feat: Add --version flag Co-Authored-By: Claude Opus 4.5 --- devlaunch/dl.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/devlaunch/dl.py b/devlaunch/dl.py index 90221710..1eed5898 100644 --- a/devlaunch/dl.py +++ b/devlaunch/dl.py @@ -25,11 +25,20 @@ import os import pathlib import re +from importlib.metadata import version as pkg_version from typing import List, Optional, Dict, Any from dataclasses import dataclass from .completion import install_completions + +def get_version() -> str: + """Get the package version.""" + try: + return pkg_version("devlaunch") + except Exception: + return "unknown" + logging.basicConfig(level=logging.INFO, format="%(message)s") @@ -509,6 +518,7 @@ def print_help(): dl --ls List all workspaces dl --install Install shell completions dl --help, -h Show this help + dl --version Show version Examples: dl # Select workspace with fzf @@ -541,6 +551,10 @@ def main() -> int: print_help() return 0 + if args[0] == "--version": + print(f"dl {get_version()}") + return 0 + if args[0] == "--ls": print_workspaces() return 0 From bdd55d06c71541cb985e5dd379049faa6522aae5 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 17 Jan 2026 16:23:46 +0000 Subject: [PATCH 03/10] feat: Add nocache as alias for recreate Co-Authored-By: Claude Opus 4.5 --- devlaunch/completions/dl.bash | 2 +- devlaunch/dl.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/devlaunch/completions/dl.bash b/devlaunch/completions/dl.bash index 8e381db8..799e2423 100644 --- a/devlaunch/completions/dl.bash +++ b/devlaunch/completions/dl.bash @@ -9,7 +9,7 @@ _dl_completion() { local global_opts="--ls --install --help" # Workspace subcommands - local ws_cmds="stop rm code status restart recreate --" + local ws_cmds="stop rm code status restart recreate nocache --" # Cache file location (honors XDG_CACHE_HOME) local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/dl" diff --git a/devlaunch/dl.py b/devlaunch/dl.py index 1eed5898..bfb756f0 100644 --- a/devlaunch/dl.py +++ b/devlaunch/dl.py @@ -512,6 +512,7 @@ def print_help(): dl status Show workspace status dl restart Reset workspace (clean slate) dl recreate Recreate workspace container + dl nocache Alias for recreate dl -- Run shell command in workspace Global commands: @@ -627,7 +628,7 @@ def main() -> int: result = workspace_up(workspace_spec, ide="vscode") return result.returncode - if subcommand == "recreate": + if subcommand in ("recreate", "nocache"): result = workspace_up(workspace_spec, recreate=True) if result.returncode != 0: return result.returncode From 58d1b84e06ff4a8b61a53dda98acb8b1f7dd590c Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 17 Jan 2026 16:25:26 +0000 Subject: [PATCH 04/10] chore: Remove nocache command (devpod doesn't support it) Co-Authored-By: Claude Opus 4.5 --- devlaunch/completions/dl.bash | 2 +- devlaunch/dl.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/devlaunch/completions/dl.bash b/devlaunch/completions/dl.bash index 799e2423..8e381db8 100644 --- a/devlaunch/completions/dl.bash +++ b/devlaunch/completions/dl.bash @@ -9,7 +9,7 @@ _dl_completion() { local global_opts="--ls --install --help" # Workspace subcommands - local ws_cmds="stop rm code status restart recreate nocache --" + local ws_cmds="stop rm code status restart recreate --" # Cache file location (honors XDG_CACHE_HOME) local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/dl" diff --git a/devlaunch/dl.py b/devlaunch/dl.py index bfb756f0..1eed5898 100644 --- a/devlaunch/dl.py +++ b/devlaunch/dl.py @@ -512,7 +512,6 @@ def print_help(): dl status Show workspace status dl restart Reset workspace (clean slate) dl recreate Recreate workspace container - dl nocache Alias for recreate dl -- Run shell command in workspace Global commands: @@ -628,7 +627,7 @@ def main() -> int: result = workspace_up(workspace_spec, ide="vscode") return result.returncode - if subcommand in ("recreate", "nocache"): + if subcommand == "recreate": result = workspace_up(workspace_spec, recreate=True) if result.returncode != 0: return result.returncode From fd7e6b1674217c001b6dc661643cf254a9128ac2 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 17 Jan 2026 16:30:38 +0000 Subject: [PATCH 05/10] refactor: Reorganize restart/reset/recreate commands - restart: stop + start (no rebuild) - recreate: rebuild container (devpod --recreate) - reset: clean slate, remove all and recreate (devpod --reset) - Remove status command Co-Authored-By: Claude Opus 4.5 --- devlaunch/completions/dl.bash | 2 +- devlaunch/dl.py | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/devlaunch/completions/dl.bash b/devlaunch/completions/dl.bash index 8e381db8..ee5a76a1 100644 --- a/devlaunch/completions/dl.bash +++ b/devlaunch/completions/dl.bash @@ -9,7 +9,7 @@ _dl_completion() { local global_opts="--ls --install --help" # Workspace subcommands - local ws_cmds="stop rm code status restart recreate --" + local ws_cmds="stop rm code restart recreate reset --" # Cache file location (honors XDG_CACHE_HOME) local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/dl" diff --git a/devlaunch/dl.py b/devlaunch/dl.py index 1eed5898..c99bd8d9 100644 --- a/devlaunch/dl.py +++ b/devlaunch/dl.py @@ -509,9 +509,9 @@ def print_help(): dl stop Stop the workspace dl rm Delete the workspace dl code Open in VS Code - dl status Show workspace status - dl restart Reset workspace (clean slate) - dl recreate Recreate workspace container + dl restart Stop and start (no rebuild) + dl recreate Recreate container + dl reset Clean slate (remove all, recreate) dl -- Run shell command in workspace Global commands: @@ -620,9 +620,6 @@ def main() -> int: if subcommand in ("rm", "prune"): return workspace_delete(workspace_id) - if subcommand == "status": - return workspace_status(workspace_id) - if subcommand == "code": result = workspace_up(workspace_spec, ide="vscode") return result.returncode @@ -634,6 +631,15 @@ def main() -> int: return workspace_ssh(workspace_id) if subcommand == "restart": + # Stop and start without rebuilding + workspace_stop(workspace_id) + result = workspace_up(workspace_spec) + if result.returncode != 0: + return result.returncode + return workspace_ssh(workspace_id) + + if subcommand == "reset": + # Clean slate - remove everything and recreate result = workspace_up(workspace_spec, reset=True) if result.returncode != 0: return result.returncode From 0482396e8cf9521177cd31d60549831248761c2d Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 17 Jan 2026 16:35:07 +0000 Subject: [PATCH 06/10] fix: Address PR review comments - Use PackageNotFoundError instead of generic Exception - Add --version and -h to bash completion - Handle workspace_stop return code in restart command - Document prune alias in help text Co-Authored-By: Claude Opus 4.5 --- devlaunch/completions/dl.bash | 2 +- devlaunch/dl.py | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/devlaunch/completions/dl.bash b/devlaunch/completions/dl.bash index ee5a76a1..e3741c43 100644 --- a/devlaunch/completions/dl.bash +++ b/devlaunch/completions/dl.bash @@ -6,7 +6,7 @@ _dl_completion() { prev="${COMP_WORDS[COMP_CWORD-1]}" # Global command options (only valid as first arg) - local global_opts="--ls --install --help" + local global_opts="--ls --install --help -h --version" # Workspace subcommands local ws_cmds="stop rm code restart recreate reset --" diff --git a/devlaunch/dl.py b/devlaunch/dl.py index c99bd8d9..4d3d6e94 100644 --- a/devlaunch/dl.py +++ b/devlaunch/dl.py @@ -25,7 +25,7 @@ import os import pathlib import re -from importlib.metadata import version as pkg_version +from importlib.metadata import version as pkg_version, PackageNotFoundError from typing import List, Optional, Dict, Any from dataclasses import dataclass @@ -36,9 +36,10 @@ def get_version() -> str: """Get the package version.""" try: return pkg_version("devlaunch") - except Exception: + except PackageNotFoundError: return "unknown" + logging.basicConfig(level=logging.INFO, format="%(message)s") @@ -507,7 +508,7 @@ def print_help(): Workspace commands: dl stop Stop the workspace - dl rm Delete the workspace + dl rm, prune Delete the workspace dl code Open in VS Code dl restart Stop and start (no rebuild) dl recreate Recreate container @@ -632,7 +633,9 @@ def main() -> int: if subcommand == "restart": # Stop and start without rebuilding - workspace_stop(workspace_id) + stop_ret = workspace_stop(workspace_id) + if stop_ret != 0: + return stop_ret result = workspace_up(workspace_spec) if result.returncode != 0: return result.returncode From 0868520048cfecfaa341855b778ef14b34be3fd8 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 17 Jan 2026 16:35:50 +0000 Subject: [PATCH 07/10] update pixi.lock --- pixi.lock | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pixi.lock b/pixi.lock index 02f59a75..e124dc05 100644 --- a/pixi.lock +++ b/pixi.lock @@ -382,8 +382,8 @@ packages: requires_python: '>=3.10' - pypi: ./ name: devlaunch - version: 0.0.1 - sha256: 29de11213c212d6485b0184e65470b78cf7c2769be434d6e131844549e3fafc2 + version: 0.0.2 + sha256: 7602a22b553067716c5a94bbc39c69941e5d7842a91be6658afb2fb93cb9ab6d requires_dist: - iterfzf>=1.0.0 - pylint>=3.2.5,<=4.0.4 ; extra == 'test' diff --git a/pyproject.toml b/pyproject.toml index 737002e6..0d7c7379 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devlaunch" -version = "0.0.1" +version = "0.0.2" authors = [{ name = "Austin Gregg-Smith", email = "blooop@gmail.com" }] description = "DevLaunch - A streamlined CLI for devpod workspaces" readme = "README.md" From 13ebe06b556272d00a803c42b22319b79192ddd0 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 17 Jan 2026 16:42:02 +0000 Subject: [PATCH 08/10] feat: Add comprehensive tests and improve coverage - Add tests for get_version(), spec_to_workspace_id(), cache functions - Add tests for main() CLI commands (help, version, ls, repos, etc.) - Add tests for workspace operations (stop, rm, code, restart, etc.) - Add tests for run_devpod() and print functions - Update help text to use user/repo syntax - Coverage improved from 33% to 65% Co-Authored-By: Claude Opus 4.5 --- devlaunch/dl.py | 54 +++---- test/test_dl.py | 405 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 432 insertions(+), 27 deletions(-) diff --git a/devlaunch/dl.py b/devlaunch/dl.py index 4d3d6e94..49d14815 100644 --- a/devlaunch/dl.py +++ b/devlaunch/dl.py @@ -495,41 +495,41 @@ def print_help(): help_text = """dl - DevLaunch CLI Usage: - dl Interactive workspace selector (fzf) - dl Start workspace and attach shell - dl Run workspace command (stop, code, etc.) - dl -- Run shell command in workspace + dl Interactive workspace selector (fzf) + dl Start workspace and attach shell + dl Run workspace command (stop, code, etc.) + dl -- Run shell command in workspace Workspace sources: - dl myproject Existing workspace by name - dl owner/repo Create from GitHub repo - dl owner/repo@branch Create from specific branch - dl ./path Create from local path + dl myproject Existing workspace by name + dl user/repo Create from GitHub repo + dl user/repo@branch Create from specific branch + dl ./path Create from local path Workspace commands: - dl stop Stop the workspace - dl rm, prune Delete the workspace - dl code Open in VS Code - dl restart Stop and start (no rebuild) - dl recreate Recreate container - dl reset Clean slate (remove all, recreate) - dl -- Run shell command in workspace + dl stop Stop the workspace + dl rm, prune Delete the workspace + dl code Open in VS Code + dl restart Stop and start (no rebuild) + dl recreate Recreate container + dl reset Clean slate (remove all, recreate) + dl -- Run shell command in workspace Global commands: - dl --ls List all workspaces - dl --install Install shell completions - dl --help, -h Show this help - dl --version Show version + dl --ls List all workspaces + dl --install Install shell completions + dl --help, -h Show this help + dl --version Show version Examples: - dl # Select workspace with fzf - dl myproject # Open existing workspace - dl loft-sh/devpod # Create from GitHub - dl blooop/devlaunch@main # Create from specific branch - dl ./my-project # Create from local folder - dl myproject code # Open in VS Code - dl myproject -- make test # Run command in workspace - dl myproject stop # Stop workspace + dl # Select workspace with fzf + dl devpod # Open existing workspace + dl loft-sh/devpod # Create from GitHub + dl blooop/devlaunch@main # Create from specific branch + dl ./my-project # Create from local folder + dl blooop/devlaunch code # Open in VS Code + dl blooop/devlaunch -- make test # Run command in workspace + dl blooop/devlaunch stop # Stop workspace """ print(help_text) diff --git a/test/test_dl.py b/test/test_dl.py index a956abed..8d36d82c 100644 --- a/test/test_dl.py +++ b/test/test_dl.py @@ -1,6 +1,9 @@ """Tests for dl (DevLaunch CLI) functionality.""" import json +import sys +import tempfile +import pathlib from unittest.mock import patch, MagicMock from devlaunch.dl import ( @@ -15,6 +18,17 @@ list_workspaces, get_workspace_ids, OWNER_REPO_PATTERN, + spec_to_workspace_id, + get_version, + read_completion_cache, + write_completion_cache, + write_bash_completion_cache, + main, + print_help, + print_workspaces, + workspace_stop, + workspace_delete, + run_devpod, ) @@ -457,3 +471,394 @@ def test_get_known_repos_empty(self, mock_list): mock_list.return_value = [] repos = get_known_repos() assert repos == [] + + +class TestGetVersion: + """Tests for get_version function.""" + + def test_get_version_returns_string(self): + """Test that get_version returns a string.""" + version = get_version() + assert isinstance(version, str) + assert len(version) > 0 + + @patch("devlaunch.dl.pkg_version") + def test_get_version_package_not_found(self, mock_pkg_version): + """Test get_version returns 'unknown' when package not found.""" + from importlib.metadata import PackageNotFoundError + + mock_pkg_version.side_effect = PackageNotFoundError("devlaunch") + version = get_version() + assert version == "unknown" + + +class TestSpecToWorkspaceId: + """Tests for spec_to_workspace_id function.""" + + def test_owner_repo_extracts_repo(self): + """Test owner/repo extracts repo name.""" + assert spec_to_workspace_id("blooop/devlaunch") == "devlaunch" + + def test_owner_repo_with_branch(self): + """Test owner/repo@branch extracts repo name.""" + assert spec_to_workspace_id("blooop/devlaunch@main") == "devlaunch" + + def test_github_url(self): + """Test github.com/owner/repo extracts repo name.""" + assert spec_to_workspace_id("github.com/loft-sh/devpod") == "devpod" + + def test_https_url(self): + """Test https URL extracts repo name.""" + assert spec_to_workspace_id("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/owner/repo") == "repo" + + def test_url_with_git_suffix(self): + """Test URL with .git suffix strips it.""" + assert spec_to_workspace_id("github.com/owner/repo.git") == "repo" + + def test_path_extracts_directory_name(self): + """Test path extracts directory name.""" + result = spec_to_workspace_id("./my-project") + assert result == "my-project" + + def test_existing_workspace_id(self): + """Test existing workspace ID is returned as-is.""" + assert spec_to_workspace_id("myworkspace") == "myworkspace" + + +class TestCacheFunctions: + """Tests for cache read/write functions.""" + + def test_write_and_read_completion_cache(self): + """Test writing and reading completion cache.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch("devlaunch.dl.CACHE_FILE", pathlib.Path(tmpdir) / "cache.json"): + data = {"workspaces": ["ws1", "ws2"], "repos": ["a/b"], "owners": ["a"]} + write_completion_cache(data) + result = read_completion_cache() + assert result == data + + def test_read_nonexistent_cache(self): + """Test reading nonexistent cache returns None.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch( + "devlaunch.dl.CACHE_FILE", pathlib.Path(tmpdir) / "nonexistent.json" + ): + result = read_completion_cache() + assert result is None + + def test_write_bash_completion_cache(self): + """Test writing bash completion cache.""" + with tempfile.TemporaryDirectory() as tmpdir: + bash_file = pathlib.Path(tmpdir) / "completions.bash" + with patch("devlaunch.dl.BASH_CACHE_FILE", bash_file): + data = {"workspaces": ["ws1", "ws2"], "repos": ["a/b"], "owners": ["a"]} + write_bash_completion_cache(data) + content = bash_file.read_text() + assert 'DL_WORKSPACES="ws1 ws2"' in content + assert 'DL_REPOS="a/b"' in content + assert 'DL_OWNERS="a"' in content + + +class TestRunDevpod: + """Tests for run_devpod function.""" + + @patch("devlaunch.dl.subprocess.run") + def test_run_devpod_basic(self, mock_run): + """Test basic devpod command execution.""" + mock_run.return_value = MagicMock(returncode=0) + result = run_devpod(["list"]) + mock_run.assert_called_once() + assert result.returncode == 0 + + @patch("devlaunch.dl.subprocess.run") + def test_run_devpod_capture(self, mock_run): + """Test devpod command with capture.""" + mock_run.return_value = MagicMock(returncode=0, stdout="output") + run_devpod(["list"], capture=True) + mock_run.assert_called_once() + call_kwargs = mock_run.call_args[1] + assert call_kwargs["capture_output"] is True + + +class TestWorkspaceOperations: + """Tests for workspace operation functions.""" + + @patch("devlaunch.dl.run_devpod") + def test_workspace_stop(self, mock_run): + """Test workspace_stop calls devpod stop.""" + mock_run.return_value = MagicMock(returncode=0) + result = workspace_stop("myworkspace") + mock_run.assert_called_once_with(["stop", "myworkspace"]) + assert result == 0 + + @patch("devlaunch.dl.run_devpod") + def test_workspace_delete(self, mock_run): + """Test workspace_delete calls devpod delete.""" + mock_run.return_value = MagicMock(returncode=0) + result = workspace_delete("myworkspace") + mock_run.assert_called_once_with(["delete", "myworkspace"]) + assert result == 0 + + +class TestPrintFunctions: + """Tests for print functions.""" + + def test_print_help(self, capsys): + """Test print_help outputs help text.""" + print_help() + captured = capsys.readouterr() + assert "dl - DevLaunch CLI" in captured.out + assert "Usage:" in captured.out + assert "--ls" in captured.out + + @patch("devlaunch.dl.list_workspaces") + def test_print_workspaces(self, mock_list, capsys): + """Test print_workspaces outputs workspace table.""" + mock_list.return_value = [ + Workspace("ws1", "local", "/path/to/ws1", "2024-01-01", "docker", "vscode"), + ] + print_workspaces() + captured = capsys.readouterr() + assert "ws1" in captured.out + + @patch("devlaunch.dl.list_workspaces") + def test_print_workspaces_empty(self, mock_list, capsys): + """Test print_workspaces with no workspaces.""" + mock_list.return_value = [] + print_workspaces() + captured = capsys.readouterr() + assert "No workspaces found" in captured.out + + +class TestMainCLI: + """Tests for main() CLI entry point.""" + + def test_main_help_flag(self, capsys): + """Test --help flag shows help.""" + with patch.object(sys, "argv", ["dl", "--help"]): + result = main() + assert result == 0 + captured = capsys.readouterr() + assert "dl - DevLaunch CLI" in captured.out + + def test_main_h_flag(self, capsys): + """Test -h flag shows help.""" + with patch.object(sys, "argv", ["dl", "-h"]): + result = main() + assert result == 0 + captured = capsys.readouterr() + assert "dl - DevLaunch CLI" in captured.out + + def test_main_version_flag(self, capsys): + """Test --version flag shows version.""" + with patch.object(sys, "argv", ["dl", "--version"]): + result = main() + assert result == 0 + captured = capsys.readouterr() + assert "dl " in captured.out + + @patch("devlaunch.dl.list_workspaces") + def test_main_ls_flag(self, mock_list, capsys): + """Test --ls flag lists workspaces.""" + mock_list.return_value = [] + with patch.object(sys, "argv", ["dl", "--ls"]): + result = main() + assert result == 0 + captured = capsys.readouterr() + assert "No workspaces found" in captured.out + + @patch("devlaunch.dl.read_completion_cache") + def test_main_repos_flag(self, mock_cache, capsys): + """Test --repos flag outputs repos.""" + mock_cache.return_value = {"repos": ["owner/repo1", "owner/repo2"]} + with patch.object(sys, "argv", ["dl", "--repos"]): + result = main() + assert result == 0 + captured = capsys.readouterr() + assert "owner/repo1" in captured.out + + @patch("devlaunch.dl.update_completion_cache") + def test_main_update_cache_flag(self, mock_update): + """Test --update-cache flag updates cache.""" + mock_update.return_value = {} + with patch.object(sys, "argv", ["dl", "--update-cache"]): + result = main() + assert result == 0 + mock_update.assert_called_once() + + @patch("devlaunch.dl.read_completion_cache") + def test_main_completion_data_flag(self, mock_cache, capsys): + """Test --completion-data flag outputs JSON.""" + mock_cache.return_value = {"workspaces": ["ws1"], "repos": [], "owners": []} + with patch.object(sys, "argv", ["dl", "--completion-data"]): + result = main() + assert result == 0 + captured = capsys.readouterr() + data = json.loads(captured.out) + assert "workspaces" in data + + @patch("devlaunch.dl.update_completion_cache") + @patch("devlaunch.dl.install_completions") + def test_main_install_flag(self, mock_install, mock_update): + """Test --install flag installs completions.""" + mock_install.return_value = 0 + mock_update.return_value = {} + with patch.object(sys, "argv", ["dl", "--install"]): + result = main() + assert result == 0 + mock_install.assert_called_once() + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_stop") + def test_main_workspace_stop(self, mock_stop, mock_ids): + """Test workspace stop command.""" + mock_ids.return_value = ["myws"] + mock_stop.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "stop"]): + result = main() + assert result == 0 + mock_stop.assert_called_once_with("myws") + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_delete") + def test_main_workspace_rm(self, mock_delete, mock_ids): + """Test workspace rm command.""" + mock_ids.return_value = ["myws"] + mock_delete.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "rm"]): + result = main() + assert result == 0 + mock_delete.assert_called_once_with("myws") + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_delete") + def test_main_workspace_prune(self, mock_delete, mock_ids): + """Test workspace prune command (alias for rm).""" + mock_ids.return_value = ["myws"] + mock_delete.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "prune"]): + result = main() + assert result == 0 + mock_delete.assert_called_once() + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_up") + def test_main_workspace_code(self, mock_up, mock_ids): + """Test workspace code command.""" + mock_ids.return_value = ["myws"] + mock_up.return_value = MagicMock(returncode=0) + with patch.object(sys, "argv", ["dl", "myws", "code"]): + result = main() + assert result == 0 + mock_up.assert_called_once_with("myws", ide="vscode") + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_up") + @patch("devlaunch.dl.workspace_ssh") + def test_main_workspace_recreate(self, mock_ssh, mock_up, mock_ids): + """Test workspace recreate command.""" + mock_ids.return_value = ["myws"] + mock_up.return_value = MagicMock(returncode=0) + mock_ssh.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "recreate"]): + result = main() + assert result == 0 + mock_up.assert_called_once_with("myws", recreate=True) + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_stop") + @patch("devlaunch.dl.workspace_up") + @patch("devlaunch.dl.workspace_ssh") + def test_main_workspace_restart(self, mock_ssh, mock_up, mock_stop, mock_ids): + """Test workspace restart command.""" + mock_ids.return_value = ["myws"] + mock_stop.return_value = 0 + mock_up.return_value = MagicMock(returncode=0) + mock_ssh.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "restart"]): + result = main() + assert result == 0 + mock_stop.assert_called_once() + mock_up.assert_called_once() + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_up") + @patch("devlaunch.dl.workspace_ssh") + def test_main_workspace_reset(self, mock_ssh, mock_up, mock_ids): + """Test workspace reset command.""" + mock_ids.return_value = ["myws"] + mock_up.return_value = MagicMock(returncode=0) + mock_ssh.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "reset"]): + result = main() + assert result == 0 + mock_up.assert_called_once_with("myws", reset=True) + + @patch("devlaunch.dl.get_workspace_ids") + def test_main_unknown_command_error(self, mock_ids, caplog): + """Test unknown subcommand returns error.""" + mock_ids.return_value = ["myws"] + with patch.object(sys, "argv", ["dl", "myws", "badcmd"]): + result = main() + assert result == 1 + assert "Unknown command" in caplog.text + + @patch("devlaunch.dl.get_workspace_ids") + def test_main_invalid_workspace_error(self, mock_ids, caplog): + """Test invalid workspace spec returns error.""" + mock_ids.return_value = [] + with patch.object(sys, "argv", ["dl", "nonexistent"]): + result = main() + assert result == 1 + assert "Unknown workspace" in caplog.text + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_up") + @patch("devlaunch.dl.workspace_ssh") + @patch("devlaunch.dl.update_cache_background") + def test_main_workspace_shell_command(self, _cache, mock_ssh, mock_up, mock_ids): + """Test running shell command with -- separator.""" + mock_ids.return_value = ["myws"] + mock_up.return_value = MagicMock(returncode=0) + mock_ssh.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws", "--", "echo", "hello"]): + result = main() + assert result == 0 + mock_ssh.assert_called_once_with("myws", "echo hello") + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.workspace_up") + @patch("devlaunch.dl.workspace_ssh") + @patch("devlaunch.dl.update_cache_background") + def test_main_workspace_default(self, _cache, mock_ssh, mock_up, mock_ids): + """Test default workspace start and attach.""" + mock_ids.return_value = ["myws"] + mock_up.return_value = MagicMock(returncode=0) + mock_ssh.return_value = 0 + with patch.object(sys, "argv", ["dl", "myws"]): + result = main() + assert result == 0 + mock_up.assert_called_once() + mock_ssh.assert_called_once() + + @patch("devlaunch.dl.get_workspace_ids") + @patch("devlaunch.dl.expand_workspace_spec") + @patch("devlaunch.dl.spec_to_workspace_id") + @patch("devlaunch.dl.workspace_up") + @patch("devlaunch.dl.workspace_ssh") + @patch("devlaunch.dl.update_cache_background") + def test_main_new_workspace_from_repo( + self, _cache, mock_ssh, mock_up, mock_spec_id, mock_expand, mock_ids + ): + """Test creating workspace from owner/repo.""" + mock_ids.return_value = [] # Not existing + mock_expand.return_value = "github.com/owner/repo" + mock_spec_id.return_value = "repo" + mock_up.return_value = MagicMock(returncode=0) + mock_ssh.return_value = 0 + with patch.object(sys, "argv", ["dl", "owner/repo"]): + result = main() + assert result == 0 + mock_expand.assert_called() + mock_up.assert_called_once_with("github.com/owner/repo") + mock_ssh.assert_called_once_with("repo", None) From ffe19a6a78d705797475c4bd2f6fea35002bd4fc Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 17 Jan 2026 16:44:43 +0000 Subject: [PATCH 09/10] autoformat code --- test/test_dl.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/test_dl.py b/test/test_dl.py index 8d36d82c..596e3787 100644 --- a/test/test_dl.py +++ b/test/test_dl.py @@ -540,9 +540,7 @@ def test_write_and_read_completion_cache(self): def test_read_nonexistent_cache(self): """Test reading nonexistent cache returns None.""" with tempfile.TemporaryDirectory() as tmpdir: - with patch( - "devlaunch.dl.CACHE_FILE", pathlib.Path(tmpdir) / "nonexistent.json" - ): + with patch("devlaunch.dl.CACHE_FILE", pathlib.Path(tmpdir) / "nonexistent.json"): result = read_completion_cache() assert result is None From dc7df4d0bacbf125c9869095bc0ab3d644539c6d Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 17 Jan 2026 16:47:20 +0000 Subject: [PATCH 10/10] fix: Disable too-many-positional-arguments pylint check Test functions with multiple @patch decorators commonly have many parameters. This check was added in newer pylint versions and is already conceptually covered by the disabled too-many-arguments check. Co-Authored-By: Claude Opus 4.5 --- pixi.lock | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pixi.lock b/pixi.lock index e124dc05..e4eab6d1 100644 --- a/pixi.lock +++ b/pixi.lock @@ -383,7 +383,7 @@ packages: - pypi: ./ name: devlaunch version: 0.0.2 - sha256: 7602a22b553067716c5a94bbc39c69941e5d7842a91be6658afb2fb93cb9ab6d + sha256: 93ade45ee736ac6d8740aad89fdf34015b108de4c3e8bba8f6bc186cfc770af3 requires_dist: - iterfzf>=1.0.0 - pylint>=3.2.5,<=4.0.4 ; extra == 'test' diff --git a/pyproject.toml b/pyproject.toml index 0d7c7379..914b81db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,7 +109,7 @@ extension-pkg-whitelist = ["numpy"] jobs = 16 #detect number of cores [tool.pylint.'MESSAGES CONTROL'] -disable = "C,logging-fstring-interpolation,line-too-long,fixme,broad-exception-caught,missing-module-docstring,too-many-instance-attributes,too-few-public-methods,too-many-arguments,too-many-locals,too-many-branches,too-many-statements,too-many-return-statements,use-dict-literal,cyclic-import,duplicate-code,too-many-public-methods" +disable = "C,logging-fstring-interpolation,line-too-long,fixme,broad-exception-caught,missing-module-docstring,too-many-instance-attributes,too-few-public-methods,too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-branches,too-many-statements,too-many-return-statements,use-dict-literal,cyclic-import,duplicate-code,too-many-public-methods" enable = "no-else-return,consider-using-in" [tool.coverage.run]