devlaunch 0.0.1__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
devlaunch/__init__.py ADDED
File without changes
@@ -0,0 +1,100 @@
1
+ """Utilities for installing shell autocompletion scripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import pathlib
8
+ from typing import Optional
9
+
10
+ from .completion_loader import load_completion_script
11
+
12
+ _RC_BLOCK_START = "# >>> devlaunch completions >>>"
13
+ _RC_BLOCK_END = "# <<< devlaunch completions <<<"
14
+
15
+ # Legacy blocks to clean up from previous installations
16
+ _LEGACY_BLOCKS = {
17
+ "# dl completion": "# end dl completion",
18
+ "# dp completion": "# end dp completion",
19
+ _RC_BLOCK_START: _RC_BLOCK_END,
20
+ }
21
+
22
+ _LEGACY_SINGLE_LINES = {
23
+ "complete -F _dl_completion dl",
24
+ "complete -F _dp_completion dp",
25
+ }
26
+
27
+
28
+ def _completion_file_path() -> pathlib.Path:
29
+ """Return the path where the completion script should be stored."""
30
+ override = os.environ.get("DEVLAUNCH_COMPLETION_FILE")
31
+ if override:
32
+ return pathlib.Path(override).expanduser()
33
+ return pathlib.Path.home() / ".config" / "devlaunch" / "completions.sh"
34
+
35
+
36
+ def install_completions(rc_path: Optional[pathlib.Path] = None) -> int:
37
+ """Install or refresh completion scripts for dl."""
38
+ completion_path = _completion_file_path().expanduser()
39
+ rc_target = (rc_path if rc_path is not None else pathlib.Path.home() / ".bashrc").expanduser()
40
+
41
+ try:
42
+ completion_path.parent.mkdir(parents=True, exist_ok=True)
43
+
44
+ # Write the dl completion script
45
+ completion_script = load_completion_script("dl").rstrip() + "\n"
46
+ completion_path.write_text(completion_script, encoding="utf-8")
47
+ logging.info("Wrote completion script to %s", completion_path)
48
+
49
+ rc_target.parent.mkdir(parents=True, exist_ok=True)
50
+ existing_content = ""
51
+ if rc_target.exists():
52
+ existing_content = rc_target.read_text(encoding="utf-8")
53
+
54
+ # Remove any existing completion block and legacy blocks before appending
55
+ def remove_block(text: str, start: str, end: str) -> str:
56
+ lines = text.splitlines()
57
+ out = []
58
+ skip = False
59
+ for line in lines:
60
+ if line.strip() == start:
61
+ skip = True
62
+ continue
63
+ if skip and line.strip() == end:
64
+ skip = False
65
+ continue
66
+ if not skip:
67
+ out.append(line)
68
+ return "\n".join(out)
69
+
70
+ # Remove main block
71
+ cleaned_content = remove_block(existing_content, _RC_BLOCK_START, _RC_BLOCK_END)
72
+ # Remove legacy blocks
73
+ for block_start, block_end in _LEGACY_BLOCKS.items():
74
+ cleaned_content = remove_block(cleaned_content, block_start, block_end)
75
+ # Remove legacy single lines
76
+ cleaned_lines = []
77
+ for line in cleaned_content.splitlines():
78
+ if not any(line.strip().startswith(single) for single in _LEGACY_SINGLE_LINES):
79
+ cleaned_lines.append(line)
80
+ cleaned_content = "\n".join(cleaned_lines).strip()
81
+ escaped_path = str(completion_path).replace('"', r"\"")
82
+ source_line = f'source "{escaped_path}"'
83
+ block = "\n".join([_RC_BLOCK_START, source_line, _RC_BLOCK_END])
84
+
85
+ if cleaned_content:
86
+ new_content = f"{cleaned_content}\n\n{block}\n"
87
+ else:
88
+ new_content = f"{block}\n"
89
+
90
+ rc_target.write_text(new_content, encoding="utf-8")
91
+ logging.info("Added completion source block to %s", rc_target)
92
+ logging.info("Run 'source %s' or restart your terminal to enable completion", rc_target)
93
+ print(
94
+ "[devlaunch] Autocomplete has been updated. Run 'source %s' or restart your terminal to enable completion."
95
+ % rc_target
96
+ )
97
+ return 0
98
+ except OSError as error:
99
+ logging.error("Failed to install completions: %s", error)
100
+ return 1
@@ -0,0 +1,12 @@
1
+ """Helpers for loading embedded completion scripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import resources
6
+
7
+
8
+ def load_completion_script(name: str) -> str:
9
+ """Return the text of the embedded completion script."""
10
+ package = "devlaunch.completions"
11
+ resource = resources.files(package).joinpath(f"{name}.bash")
12
+ return resource.read_text(encoding="utf-8")
File without changes
@@ -0,0 +1,78 @@
1
+ # dl completion
2
+ _dl_completion() {
3
+ local cur prev opts
4
+ COMPREPLY=()
5
+ cur="${COMP_WORDS[COMP_CWORD]}"
6
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
7
+
8
+ # Command options
9
+ opts="--ls --repos --stop --rm --code --status --recreate --reset --install --help"
10
+
11
+ # Flag completion
12
+ if [[ ${cur} == -* ]]; then
13
+ COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
14
+ return 0
15
+ fi
16
+
17
+ # Cache file location (honors XDG_CACHE_HOME)
18
+ local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/dl"
19
+ local cache_file="$cache_dir/completions.bash"
20
+
21
+ # Initialize completion variables
22
+ local DL_WORKSPACES=""
23
+ local DL_REPOS=""
24
+ local DL_OWNERS=""
25
+
26
+ # Source the bash cache file (fast, no jq needed)
27
+ if [[ -f "$cache_file" ]]; then
28
+ source "$cache_file"
29
+ fi
30
+
31
+ # Commands that need workspace completion
32
+ if [[ "$prev" == "--stop" || "$prev" == "--rm" || "$prev" == "--code" || "$prev" == "--status" || "$prev" == "--recreate" || "$prev" == "--reset" ]]; then
33
+ if [[ -n "$DL_WORKSPACES" ]]; then
34
+ COMPREPLY=( $(compgen -W "${DL_WORKSPACES}" -- ${cur}) )
35
+ fi
36
+ return 0
37
+ fi
38
+
39
+ # First positional argument: workspace, owner/repo, or path
40
+ if [[ ${COMP_CWORD} -eq 1 ]]; then
41
+ # Don't add space after completion to allow @branch suffix
42
+ compopt -o nospace
43
+
44
+ # If typing a path, complete files/directories
45
+ if [[ "$cur" == ./* || "$cur" == /* || "$cur" == ~/* ]]; then
46
+ compopt +o nospace
47
+ COMPREPLY=( $(compgen -d -- ${cur}) )
48
+ return 0
49
+ fi
50
+
51
+ # Check if completing owner/repo format (contains /)
52
+ if [[ "$cur" == */* ]]; then
53
+ # Complete from known repos
54
+ if [[ -n "$DL_REPOS" ]]; then
55
+ COMPREPLY=( $(compgen -W "${DL_REPOS}" -- ${cur}) )
56
+ fi
57
+ return 0
58
+ fi
59
+
60
+ # Default: complete workspace names and offer owner/ completion
61
+ local completions="$DL_WORKSPACES"
62
+
63
+ # Add owners with trailing slash
64
+ for owner in $DL_OWNERS; do
65
+ completions="$completions ${owner}/"
66
+ done
67
+
68
+ if [[ -n "$completions" ]]; then
69
+ COMPREPLY=( $(compgen -W "${completions}" -- ${cur}) )
70
+ fi
71
+ return 0
72
+ fi
73
+
74
+ return 0
75
+ }
76
+
77
+ complete -F _dl_completion dl
78
+ # end dl completion
devlaunch/dl.py ADDED
@@ -0,0 +1,684 @@
1
+ """
2
+ dl - DevLaunch CLI
3
+
4
+ A streamlined CLI for devpod with intuitive autocomplete and fzf fuzzy selection.
5
+ Provides an renv-like UX for managing devcontainer workspaces.
6
+
7
+ Usage:
8
+ dl # fzf selector for existing workspaces
9
+ dl <workspace> # open/create workspace, attach shell
10
+ dl <workspace> <command> # run command in workspace
11
+ dl owner/repo # create from git repo (github.com)
12
+ dl owner/repo@branch # specific branch
13
+ dl ./path # create from local path
14
+ dl --ls # list workspaces
15
+ dl --stop <workspace> # stop workspace
16
+ dl --rm <workspace> # delete workspace
17
+ dl --code <workspace> # open in VS Code
18
+ dl --install # install completions
19
+ """
20
+
21
+ import sys
22
+ import subprocess
23
+ import json
24
+ import logging
25
+ import os
26
+ import pathlib
27
+ import re
28
+ from typing import List, Optional, Dict, Any
29
+ from dataclasses import dataclass
30
+
31
+ from .completion import install_completions
32
+
33
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
34
+
35
+
36
+ def _get_cache_dir() -> pathlib.Path:
37
+ """Get the cache directory, honoring XDG_CACHE_HOME."""
38
+ xdg_cache = os.environ.get("XDG_CACHE_HOME")
39
+ if xdg_cache:
40
+ return pathlib.Path(xdg_cache) / "dl"
41
+ return pathlib.Path.home() / ".cache" / "dl"
42
+
43
+
44
+ # Cache configuration (honors XDG_CACHE_HOME)
45
+ CACHE_DIR = _get_cache_dir()
46
+ CACHE_FILE = CACHE_DIR / "completions.json"
47
+ BASH_CACHE_FILE = CACHE_DIR / "completions.bash"
48
+
49
+
50
+ def get_cache_path() -> pathlib.Path:
51
+ """Get the path to the completion cache file."""
52
+ return CACHE_FILE
53
+
54
+
55
+ def read_completion_cache() -> Optional[Dict[str, Any]]:
56
+ """Read completion data from cache file."""
57
+ cache_path = get_cache_path()
58
+ if not cache_path.exists():
59
+ return None
60
+ try:
61
+ with open(cache_path, encoding="utf-8") as f:
62
+ return json.load(f)
63
+ except (OSError, json.JSONDecodeError):
64
+ return None
65
+
66
+
67
+ def write_completion_cache(data: Dict[str, Any]) -> None:
68
+ """Write completion data to cache file (JSON format)."""
69
+ cache_path = get_cache_path()
70
+ try:
71
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
72
+ with open(cache_path, "w", encoding="utf-8") as f:
73
+ json.dump(data, f)
74
+ except OSError:
75
+ pass
76
+
77
+
78
+ def write_bash_completion_cache(data: Dict[str, Any]) -> None:
79
+ """Write completion data as a sourceable bash file."""
80
+ try:
81
+ BASH_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
82
+ workspaces = " ".join(data.get("workspaces", []))
83
+ repos = " ".join(data.get("repos", []))
84
+ owners = " ".join(data.get("owners", []))
85
+ lines = [
86
+ "# Auto-generated by dl - do not edit",
87
+ f'DL_WORKSPACES="{workspaces}"',
88
+ f'DL_REPOS="{repos}"',
89
+ f'DL_OWNERS="{owners}"',
90
+ ]
91
+ with open(BASH_CACHE_FILE, "w", encoding="utf-8") as f:
92
+ f.write("\n".join(lines) + "\n")
93
+ except OSError:
94
+ pass
95
+
96
+
97
+ def update_completion_cache() -> Dict[str, Any]:
98
+ """Update the completion cache with current data."""
99
+ workspaces = list_workspaces()
100
+ workspace_ids = [ws.id for ws in workspaces]
101
+ repos = discover_repos_from_workspaces(workspaces)
102
+
103
+ # Flatten repos to list of owner/repo strings
104
+ known_repos = []
105
+ for owner, repo_list in sorted(repos.items()):
106
+ for repo in sorted(repo_list):
107
+ known_repos.append(f"{owner}/{repo}")
108
+
109
+ # Extract unique owners
110
+ owners = sorted(repos.keys())
111
+
112
+ data = {
113
+ "workspaces": workspace_ids,
114
+ "repos": known_repos,
115
+ "owners": owners,
116
+ }
117
+ write_completion_cache(data)
118
+ write_bash_completion_cache(data)
119
+ return data
120
+
121
+
122
+ def update_cache_background() -> None:
123
+ """Update completion cache in background."""
124
+ try:
125
+ # pylint: disable=consider-using-with
126
+ subprocess.Popen(
127
+ [sys.executable, "-m", "devlaunch.dl", "--update-cache"],
128
+ stdout=subprocess.DEVNULL,
129
+ stderr=subprocess.DEVNULL,
130
+ start_new_session=True,
131
+ )
132
+ except OSError:
133
+ pass
134
+
135
+
136
+ # Regex to match owner/repo[@branch] format (not a path, not already a URL)
137
+
138
+ OWNER_REPO_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(@[a-zA-Z0-9_./%-]+)?$")
139
+
140
+
141
+ def is_path_spec(spec: str) -> bool:
142
+ """Check if spec looks like a filesystem path."""
143
+ return spec.startswith("./") or spec.startswith("/") or spec.startswith("~")
144
+
145
+
146
+ def is_git_spec(spec: str) -> bool:
147
+ """Check if spec looks like a git repo (owner/repo or URL)."""
148
+ # Paths are not git specs
149
+ if is_path_spec(spec):
150
+ return False
151
+ if "://" in spec:
152
+ return True
153
+ if spec.startswith("github.com/") or spec.startswith("gitlab.com/"):
154
+ return True
155
+ return bool(OWNER_REPO_PATTERN.match(spec))
156
+
157
+
158
+ def expand_workspace_spec(spec: str) -> str:
159
+ """Expand owner/repo[@branch] to github.com/owner/repo[@branch] for devpod."""
160
+ # Don't expand if it's a path
161
+ if is_path_spec(spec):
162
+ return spec
163
+ # Don't expand if it already looks like a URL
164
+ if "://" in spec or spec.startswith("github.com/") or spec.startswith("gitlab.com/"):
165
+ return spec
166
+ # Check if it matches owner/repo[@branch] pattern
167
+ if OWNER_REPO_PATTERN.match(spec):
168
+ return f"github.com/{spec}"
169
+ # Otherwise return as-is (existing workspace name)
170
+ return spec
171
+
172
+
173
+ def spec_to_workspace_id(spec: str) -> str:
174
+ """Derive the workspace ID that devpod will use for a given spec.
175
+
176
+ Devpod uses:
177
+ - For git repos: the repo name (e.g., owner/repo -> repo)
178
+ - For paths: the directory name (e.g., ./my-project -> my-project)
179
+ - For existing IDs: the ID as-is
180
+ """
181
+ # Strip @branch suffix if present
182
+ base_spec = spec.split("@")[0]
183
+
184
+ # For paths, use the directory name
185
+ if is_path_spec(base_spec):
186
+ return pathlib.Path(base_spec).expanduser().resolve().name
187
+
188
+ # For git URLs or owner/repo, extract repo name
189
+ if is_git_spec(base_spec):
190
+ # Handle various formats: github.com/owner/repo, owner/repo, https://...
191
+ parts = base_spec.rstrip("/").split("/")
192
+ repo_name = parts[-1]
193
+ # Remove .git suffix if present
194
+ if repo_name.endswith(".git"):
195
+ repo_name = repo_name[:-4]
196
+ return repo_name
197
+
198
+ # Otherwise assume it's already a workspace ID
199
+ return spec
200
+
201
+
202
+ def validate_workspace_spec(spec: str, existing_ids: List[str]) -> Optional[str]:
203
+ """Validate workspace spec and return error message if invalid."""
204
+ # Valid if it's an existing workspace
205
+ if spec in existing_ids:
206
+ return None
207
+ # Valid if it's a path
208
+ if is_path_spec(spec):
209
+ return None
210
+ # Valid if it's a git spec (owner/repo or URL)
211
+ if is_git_spec(spec):
212
+ return None
213
+ # Invalid - provide helpful error
214
+ return f"Unknown workspace '{spec}'. Use 'dl --ls' to list workspaces, or specify owner/repo or ./path"
215
+
216
+
217
+ @dataclass
218
+ class Workspace:
219
+ """Represents a devpod workspace."""
220
+
221
+ id: str
222
+ source_type: str # "local" or "git"
223
+ source: str
224
+ last_used: str
225
+ provider: str
226
+ ide: str
227
+
228
+ @classmethod
229
+ def from_json(cls, data: Dict[str, Any]) -> "Workspace":
230
+ """Parse workspace from devpod JSON output."""
231
+ source = data.get("source", {})
232
+ if "localFolder" in source:
233
+ source_type = "local"
234
+ source_path = source["localFolder"]
235
+ elif "gitRepository" in source:
236
+ source_type = "git"
237
+ source_path = source["gitRepository"]
238
+ else:
239
+ source_type = "unknown"
240
+ source_path = str(source)
241
+
242
+ return cls(
243
+ id=data.get("id", ""),
244
+ source_type=source_type,
245
+ source=source_path,
246
+ last_used=data.get("lastUsed", ""),
247
+ provider=data.get("provider", {}).get("name", ""),
248
+ ide=data.get("ide", {}).get("name", ""),
249
+ )
250
+
251
+
252
+ # Regex patterns for parsing git URLs
253
+ GIT_URL_PATTERNS = [
254
+ # git@github.com:owner/repo.git
255
+ re.compile(r"git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$"),
256
+ # https://github.com/owner/repo.git or https://github.com/owner/repo
257
+ re.compile(r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?$"),
258
+ # github.com/owner/repo
259
+ re.compile(r"^github\.com/([^/]+)/([^/]+?)(?:\.git)?$"),
260
+ ]
261
+
262
+
263
+ def parse_owner_repo_from_url(url: str) -> Optional[tuple]:
264
+ """Extract (owner, repo) from a git URL."""
265
+ for pattern in GIT_URL_PATTERNS:
266
+ match = pattern.match(url)
267
+ if match:
268
+ return (match.group(1), match.group(2))
269
+ return None
270
+
271
+
272
+ def get_git_remote_url(path: str) -> Optional[str]:
273
+ """Get the origin remote URL from a git repository."""
274
+ try:
275
+ result = subprocess.run(
276
+ ["git", "-C", path, "remote", "get-url", "origin"],
277
+ capture_output=True,
278
+ text=True,
279
+ check=False,
280
+ )
281
+ if result.returncode == 0:
282
+ return result.stdout.strip()
283
+ except (OSError, subprocess.SubprocessError):
284
+ pass
285
+ return None
286
+
287
+
288
+ def get_git_branches(path: str) -> List[str]:
289
+ """Get list of branches from a git repository."""
290
+ try:
291
+ result = subprocess.run(
292
+ ["git", "-C", path, "branch", "-r"],
293
+ capture_output=True,
294
+ text=True,
295
+ check=False,
296
+ )
297
+ if result.returncode == 0:
298
+ branches = []
299
+ for line in result.stdout.strip().split("\n"):
300
+ line = line.strip()
301
+ if line and "origin/" in line and "HEAD" not in line:
302
+ branch = line.replace("origin/", "")
303
+ branches.append(branch)
304
+ return branches
305
+ except (OSError, subprocess.SubprocessError):
306
+ pass
307
+ return []
308
+
309
+
310
+ def discover_repos_from_workspaces(workspaces: List[Workspace]) -> Dict[str, List[str]]:
311
+ """Discover owner/repo from workspace git remotes.
312
+
313
+ Returns dict mapping owner -> list of repos.
314
+ """
315
+ repos: Dict[str, List[str]] = {}
316
+
317
+ for ws in workspaces:
318
+ owner_repo = None
319
+
320
+ # For git workspaces, parse the source URL directly
321
+ if ws.source_type == "git":
322
+ owner_repo = parse_owner_repo_from_url(ws.source)
323
+
324
+ # For local workspaces, try to get git remote
325
+ elif ws.source_type == "local" and ws.source:
326
+ remote_url = get_git_remote_url(ws.source)
327
+ if remote_url:
328
+ owner_repo = parse_owner_repo_from_url(remote_url)
329
+
330
+ if owner_repo:
331
+ owner, repo = owner_repo
332
+ if owner not in repos:
333
+ repos[owner] = []
334
+ if repo not in repos[owner]:
335
+ repos[owner].append(repo)
336
+
337
+ return repos
338
+
339
+
340
+ def get_known_repos() -> List[str]:
341
+ """Get list of known owner/repo strings from workspaces."""
342
+ workspaces = list_workspaces()
343
+ repos = discover_repos_from_workspaces(workspaces)
344
+ result = []
345
+ for owner, repo_list in sorted(repos.items()):
346
+ for repo in sorted(repo_list):
347
+ result.append(f"{owner}/{repo}")
348
+ return result
349
+
350
+
351
+ def run_devpod(args: List[str], capture: bool = False) -> subprocess.CompletedProcess:
352
+ """Run a devpod command.
353
+
354
+ Security note: Using list form of subprocess.run (not shell=True) prevents
355
+ command injection. Each list element is passed as a separate argument to
356
+ the executable, so special characters are not interpreted by a shell.
357
+ """
358
+ cmd = ["devpod"] + args
359
+ logging.debug("Running: %s", " ".join(cmd))
360
+ if capture:
361
+ # nosec B603 - using list form, not shell=True; no command injection risk
362
+ return subprocess.run(cmd, capture_output=True, text=True, check=False)
363
+ # nosec B603 - using list form, not shell=True; no command injection risk
364
+ return subprocess.run(cmd, check=False)
365
+
366
+
367
+ def list_workspaces() -> List[Workspace]:
368
+ """List all devpod workspaces."""
369
+ result = run_devpod(["list", "--output", "json"], capture=True)
370
+ if result.returncode != 0 or not result.stdout.strip():
371
+ return []
372
+ try:
373
+ data = json.loads(result.stdout)
374
+ return [Workspace.from_json(ws) for ws in data]
375
+ except json.JSONDecodeError:
376
+ logging.error("Failed to parse devpod output")
377
+ return []
378
+
379
+
380
+ def get_workspace_ids() -> List[str]:
381
+ """Get list of workspace IDs for completion."""
382
+ return [ws.id for ws in list_workspaces()]
383
+
384
+
385
+ def print_workspaces():
386
+ """Print workspace list in a nice format."""
387
+ workspaces = list_workspaces()
388
+ if not workspaces:
389
+ print("No workspaces found.")
390
+ return
391
+
392
+ # Calculate column widths
393
+ id_width = max(len(ws.id) for ws in workspaces)
394
+ type_width = max(len(ws.source_type) for ws in workspaces)
395
+ source_width = max(len(ws.source) for ws in workspaces)
396
+
397
+ # Print header
398
+ print(
399
+ f"{'WORKSPACE':<{id_width}} {'TYPE':<{type_width}} {'SOURCE':<{source_width}} LAST USED"
400
+ )
401
+ print("-" * (id_width + type_width + source_width + 30))
402
+
403
+ # Print rows
404
+ for ws in workspaces:
405
+ last_used = ws.last_used[:19].replace("T", " ") if ws.last_used else "never"
406
+ print(
407
+ f"{ws.id:<{id_width}} {ws.source_type:<{type_width}} {ws.source:<{source_width}} {last_used}"
408
+ )
409
+
410
+
411
+ def fuzzy_select_workspace() -> Optional[str]:
412
+ """Interactive fuzzy finder for workspace selection."""
413
+ try:
414
+ from iterfzf import iterfzf
415
+ except ImportError:
416
+ logging.error("iterfzf not available. Install with: pip install iterfzf")
417
+ return None
418
+
419
+ workspaces = list_workspaces()
420
+ if not workspaces:
421
+ logging.info("No workspaces found. Create one with: dl owner/repo or dl ./path")
422
+ return None
423
+
424
+ # Format options for display: "id | type | source"
425
+ options = []
426
+ ws_map = {}
427
+ for ws in workspaces:
428
+ label = f"{ws.id} | {ws.source_type} | {ws.source}"
429
+ options.append(label)
430
+ ws_map[label] = ws.id
431
+
432
+ print("Select workspace (type to filter):")
433
+ try:
434
+ selected = iterfzf(options, multi=False)
435
+ except KeyboardInterrupt:
436
+ return None
437
+ if selected:
438
+ return ws_map.get(selected)
439
+ return None
440
+
441
+
442
+ def workspace_up(
443
+ workspace: str, ide: Optional[str] = None, recreate: bool = False, reset: bool = False
444
+ ):
445
+ """Start or create a workspace."""
446
+ args = ["up", workspace]
447
+ if ide:
448
+ args.extend(["--ide", ide])
449
+ if recreate:
450
+ args.append("--recreate")
451
+ if reset:
452
+ args.append("--reset")
453
+ return run_devpod(args)
454
+
455
+
456
+ def workspace_ssh(workspace: str, command: Optional[str] = None) -> int:
457
+ """SSH into a workspace, optionally running a command."""
458
+ args = ["ssh", workspace]
459
+ if command:
460
+ args.extend(["--command", command])
461
+ result = run_devpod(args)
462
+ return result.returncode
463
+
464
+
465
+ def workspace_stop(workspace: str) -> int:
466
+ """Stop a workspace."""
467
+ result = run_devpod(["stop", workspace])
468
+ return result.returncode
469
+
470
+
471
+ def workspace_delete(workspace: str) -> int:
472
+ """Delete a workspace."""
473
+ result = run_devpod(["delete", workspace])
474
+ return result.returncode
475
+
476
+
477
+ def workspace_status(workspace: str) -> int:
478
+ """Get status of a workspace."""
479
+ result = run_devpod(["status", workspace])
480
+ return result.returncode
481
+
482
+
483
+ def print_help():
484
+ """Print usage help."""
485
+ help_text = """dl - DevLaunch CLI
486
+
487
+ Usage:
488
+ dl Interactive workspace selector (fzf)
489
+ dl <workspace> Start workspace and attach shell
490
+ dl <workspace> <command> Run command in workspace
491
+ dl owner/repo Create workspace from GitHub repo
492
+ dl owner/repo@branch Create workspace from specific branch
493
+ dl ./path Create workspace from local path
494
+
495
+ Commands:
496
+ --ls List all workspaces
497
+ --stop <workspace> Stop a workspace
498
+ --rm <workspace> Delete a workspace
499
+ --code <workspace> Open workspace in VS Code
500
+ --status <workspace> Show workspace status
501
+ --recreate <workspace> Recreate workspace container
502
+ --reset <workspace> Reset workspace (clean slate)
503
+ --install Install shell completions
504
+ --help, -h Show this help
505
+
506
+ Examples:
507
+ dl # Select workspace with fzf
508
+ dl myproject # Open existing workspace
509
+ dl loft-sh/devpod # Create from GitHub
510
+ dl blooop/devlaunch@main # Create from specific branch
511
+ dl ./my-project # Create from local folder
512
+ dl --code myproject # Open in VS Code
513
+ dl myproject 'make test' # Run command in workspace
514
+ """
515
+ print(help_text)
516
+
517
+
518
+ def main() -> int:
519
+ """Main entry point for dl CLI."""
520
+ args = sys.argv[1:]
521
+
522
+ # Handle help
523
+ if not args or (len(args) == 1 and args[0] in ("--help", "-h")):
524
+ if not args:
525
+ # No args - try fzf selection
526
+ selected = fuzzy_select_workspace()
527
+ if not selected:
528
+ print_help()
529
+ return 1
530
+ workspace_up(selected)
531
+ return workspace_ssh(selected)
532
+ print_help()
533
+ return 0
534
+
535
+ # Handle flags
536
+ if args[0] == "--ls":
537
+ print_workspaces()
538
+ return 0
539
+
540
+ if args[0] == "--repos":
541
+ # Output known repos for bash completion (uses cache if available)
542
+ cache = read_completion_cache()
543
+ if cache and "repos" in cache:
544
+ for repo in cache["repos"]:
545
+ print(repo)
546
+ else:
547
+ for repo in get_known_repos():
548
+ print(repo)
549
+ return 0
550
+
551
+ if args[0] == "--update-cache":
552
+ # Update completion cache (called in background)
553
+ update_completion_cache()
554
+ return 0
555
+
556
+ if args[0] == "--completion-data":
557
+ # Output all completion data as JSON (fast, from cache)
558
+ cache = read_completion_cache()
559
+ if cache:
560
+ print(json.dumps(cache))
561
+ else:
562
+ # No cache, generate and cache it
563
+ data = update_completion_cache()
564
+ print(json.dumps(data))
565
+ return 0
566
+
567
+ if args[0] == "--install":
568
+ rc_path = None
569
+ if len(args) > 1:
570
+ rc_path = pathlib.Path(args[1])
571
+ # Generate cache so completions work immediately
572
+ update_completion_cache()
573
+ return install_completions(rc_path)
574
+
575
+ if args[0] == "--stop":
576
+ if len(args) < 2:
577
+ workspace = fuzzy_select_workspace()
578
+ if not workspace:
579
+ logging.error("Usage: dl --stop <workspace>")
580
+ return 1
581
+ else:
582
+ workspace = args[1]
583
+ return workspace_stop(workspace)
584
+
585
+ if args[0] == "--rm":
586
+ if len(args) < 2:
587
+ workspace = fuzzy_select_workspace()
588
+ if not workspace:
589
+ logging.error("Usage: dl --rm <workspace>")
590
+ return 1
591
+ else:
592
+ workspace = args[1]
593
+ return workspace_delete(workspace)
594
+
595
+ if args[0] == "--status":
596
+ if len(args) < 2:
597
+ workspace = fuzzy_select_workspace()
598
+ if not workspace:
599
+ logging.error("Usage: dl --status <workspace>")
600
+ return 1
601
+ else:
602
+ workspace = args[1]
603
+ return workspace_status(workspace)
604
+
605
+ if args[0] == "--code":
606
+ if len(args) < 2:
607
+ workspace = fuzzy_select_workspace()
608
+ if not workspace:
609
+ logging.error("Usage: dl --code <workspace>")
610
+ return 1
611
+ else:
612
+ workspace = args[1]
613
+ result = workspace_up(workspace, ide="vscode")
614
+ return result.returncode
615
+
616
+ if args[0] == "--recreate":
617
+ if len(args) < 2:
618
+ workspace = fuzzy_select_workspace()
619
+ if not workspace:
620
+ logging.error("Usage: dl --recreate <workspace>")
621
+ return 1
622
+ else:
623
+ workspace = args[1]
624
+ workspace_spec = expand_workspace_spec(workspace)
625
+ workspace_id = spec_to_workspace_id(workspace)
626
+ result = workspace_up(workspace_spec, recreate=True)
627
+ if result.returncode != 0:
628
+ return result.returncode
629
+ return workspace_ssh(workspace_id)
630
+
631
+ if args[0] == "--reset":
632
+ if len(args) < 2:
633
+ workspace = fuzzy_select_workspace()
634
+ if not workspace:
635
+ logging.error("Usage: dl --reset <workspace>")
636
+ return 1
637
+ else:
638
+ workspace = args[1]
639
+ workspace_spec = expand_workspace_spec(workspace)
640
+ workspace_id = spec_to_workspace_id(workspace)
641
+ result = workspace_up(workspace_spec, reset=True)
642
+ if result.returncode != 0:
643
+ return result.returncode
644
+ return workspace_ssh(workspace_id)
645
+
646
+ # Default: workspace name and optional command
647
+ raw_spec = args[0]
648
+ command = " ".join(args[1:]) if len(args) > 1 else None
649
+
650
+ # Validate the workspace spec
651
+ existing_ids = get_workspace_ids()
652
+ error = validate_workspace_spec(raw_spec, existing_ids)
653
+ if error:
654
+ logging.error(error)
655
+ return 1
656
+
657
+ # Use raw spec as-is if it's an existing workspace ID, otherwise expand
658
+ # This prevents owner/repo-style workspace IDs from being rewritten
659
+ if raw_spec in existing_ids:
660
+ workspace_spec = raw_spec
661
+ workspace_id = raw_spec
662
+ else:
663
+ workspace_spec = expand_workspace_spec(raw_spec)
664
+ workspace_id = spec_to_workspace_id(raw_spec)
665
+
666
+ # Start the workspace
667
+ result = workspace_up(workspace_spec)
668
+ if result.returncode != 0:
669
+ return result.returncode
670
+
671
+ # Attach to workspace using the ID (not the full spec)
672
+ ret = workspace_ssh(workspace_id, command)
673
+
674
+ # Update cache in background after workspace operations
675
+ update_cache_background()
676
+
677
+ return ret
678
+
679
+
680
+ if __name__ == "__main__":
681
+ try:
682
+ sys.exit(main())
683
+ except KeyboardInterrupt:
684
+ sys.exit(130)
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: devlaunch
3
+ Version: 0.0.1
4
+ Summary: DevLaunch - A streamlined CLI for devpod workspaces
5
+ Project-URL: Source, https://github.com/blooop/devlaunch
6
+ Project-URL: Home, https://github.com/blooop/devlaunch
7
+ Author-email: Austin Gregg-Smith <blooop@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Dist: iterfzf>=1.0.0
11
+ Provides-Extra: test
12
+ Requires-Dist: coverage<=7.13.1,>=7.5.4; extra == 'test'
13
+ Requires-Dist: hypothesis<=6.150.2,>=6.104.2; extra == 'test'
14
+ Requires-Dist: prek<0.3.0,>=0.2.28; extra == 'test'
15
+ Requires-Dist: pylint<=4.0.4,>=3.2.5; extra == 'test'
16
+ Requires-Dist: pytest-cov<=7.0.0,>=4.1; extra == 'test'
17
+ Requires-Dist: pytest<=9.0.2,>=7.4; extra == 'test'
18
+ Requires-Dist: ruff<=0.14.13,>=0.5.0; extra == 'test'
19
+ Requires-Dist: ty<=0.0.12,>=0.0.12; extra == 'test'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # devlaunch
23
+
24
+ A streamlined CLI for [devpod](https://devpod.sh) with intuitive autocomplete and fzf fuzzy selection.
25
+
26
+ ## Continuous Integration Status
27
+
28
+ [![Ci](https://github.com/blooop/devlaunch/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/blooop/devlaunch/actions/workflows/ci.yml?query=branch%3Amain)
29
+ [![Codecov](https://codecov.io/gh/blooop/devlaunch/branch/main/graph/badge.svg?token=Y212GW1PG6)](https://codecov.io/gh/blooop/devlaunch)
30
+ [![GitHub issues](https://img.shields.io/github/issues/blooop/devlaunch.svg)](https://GitHub.com/blooop/devlaunch/issues/)
31
+ [![GitHub pull-requests merged](https://badgen.net/github/merged-prs/blooop/devlaunch)](https://github.com/blooop/devlaunch/pulls?q=is%3Amerged)
32
+ [![GitHub release](https://img.shields.io/github/release/blooop/devlaunch.svg)](https://GitHub.com/blooop/devlaunch/releases/)
33
+ [![License](https://img.shields.io/github/license/blooop/devlaunch)](https://opensource.org/license/mit/)
34
+ [![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue)](https://www.python.org/downloads/)
35
+ [![Pixi Badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/prefix-dev/pixi/main/assets/badge/v0.json)](https://pixi.sh)
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ # Using pixi (recommended)
41
+ pixi global install devlaunch
42
+
43
+ # Using pip
44
+ pip install devlaunch
45
+ ```
46
+
47
+ After installation, set up shell completions:
48
+
49
+ ```bash
50
+ dl --install
51
+ source ~/.bashrc # or restart your terminal
52
+ ```
53
+
54
+ ## Usage
55
+
56
+ ```bash
57
+ dl # Interactive workspace selector (fzf)
58
+ dl <workspace> # Start workspace and attach shell
59
+ dl <workspace> <command> # Run command in workspace
60
+ dl owner/repo # Create workspace from GitHub repo
61
+ dl owner/repo@branch # Create workspace from specific branch
62
+ dl ./path # Create workspace from local path
63
+ ```
64
+
65
+ ## Commands
66
+
67
+ | Command | Description |
68
+ |---------|-------------|
69
+ | `dl --ls` | List all workspaces |
70
+ | `dl --stop <workspace>` | Stop a workspace |
71
+ | `dl --rm <workspace>` | Delete a workspace |
72
+ | `dl --code <workspace>` | Open workspace in VS Code |
73
+ | `dl --status <workspace>` | Show workspace status |
74
+ | `dl --recreate <workspace>` | Recreate workspace container |
75
+ | `dl --reset <workspace>` | Reset workspace (clean slate) |
76
+ | `dl --install` | Install shell completions |
77
+ | `dl --help` | Show help |
78
+
79
+ ## Examples
80
+
81
+ ```bash
82
+ # Select workspace interactively with fzf
83
+ dl
84
+
85
+ # Open an existing workspace
86
+ dl myproject
87
+
88
+ # Create workspace from GitHub repository
89
+ dl loft-sh/devpod
90
+
91
+ # Create workspace from specific branch
92
+ dl blooop/devlaunch@main
93
+
94
+ # Create workspace from local folder
95
+ dl ./my-project
96
+
97
+ # Open workspace in VS Code
98
+ dl --code myproject
99
+
100
+ # Run a command in workspace
101
+ dl myproject 'make test'
102
+ ```
103
+
104
+ ## Features
105
+
106
+ - **Fuzzy Selection**: When called without arguments, uses fzf for interactive workspace selection
107
+ - **Smart Completion**: Tab completion for workspaces, GitHub repos (owner/repo format), and paths
108
+ - **GitHub Shorthand**: Use `owner/repo` instead of full URLs - automatically expands to `github.com/owner/repo`
109
+ - **Branch Support**: Specify branches with `owner/repo@branch` syntax
110
+ - **Fast Autocomplete**: Completion cache for ~3ms response time (vs ~700ms without cache)
111
+
112
+ ## Shell Completion
113
+
114
+ After running `dl --install`, you get intelligent tab completion:
115
+
116
+ - Workspace names from your devpod list
117
+ - Known GitHub owners and repositories from your workspaces
118
+ - File/directory paths when starting with `./`, `/`, or `~`
119
+ - All command flags (`--ls`, `--stop`, etc.)
120
+
121
+ ## Development
122
+
123
+ This project uses [pixi](https://pixi.sh) for environment management.
124
+
125
+ ```bash
126
+ # Run tests
127
+ pixi run test
128
+
129
+ # Run full CI suite
130
+ pixi run ci
131
+
132
+ # Format and lint
133
+ pixi run style
134
+ ```
@@ -0,0 +1,11 @@
1
+ devlaunch/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ devlaunch/completion.py,sha256=qUcniRSrKqYcaRZN2UQghEv0LPEEU5DOTqGj6qPAaRA,3803
3
+ devlaunch/completion_loader.py,sha256=WnRpd54O6xvhNOTbASEcRuBJp0QiatZFXm4wCSZa0Mg,384
4
+ devlaunch/dl.py,sha256=qtS-P0LsYxd6Xn4pXKqwmt-sRqoXjLXbPm-sGtGNBhI,21995
5
+ devlaunch/completions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ devlaunch/completions/dl.bash,sha256=EYRsRDCPbgXadaXMIdMykANhTVMdE1nFstxeNBofEsc,2347
7
+ devlaunch-0.0.1.dist-info/METADATA,sha256=cQg_GsXAPRTP_URzNVLDTh0EWpLPwju3ggbOOuweBE4,4625
8
+ devlaunch-0.0.1.dist-info/WHEEL,sha256=aha0VrrYvgDJ3Xxl3db_g_MDIW-ZexDdrc_m-Hk8YY4,105
9
+ devlaunch-0.0.1.dist-info/entry_points.txt,sha256=w3g7P_f852z_BJGEkOBQzg734meOv1GXWInNpjaTUKY,41
10
+ devlaunch-0.0.1.dist-info/licenses/LICENSE,sha256=BELp7Z_UlGhotfEDzDkWiCw7MtJ12i7B3rhIjebfk0k,1075
11
+ devlaunch-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dl = devlaunch.dl:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Austin Gregg-Smith
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.