vmux-cli 0.5.4__tar.gz → 0.6.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vmux-cli
3
- Version: 0.5.4
3
+ Version: 0.6.0
4
4
  Summary: Run anything in the cloud. Replace uv run with vmux run.
5
5
  Project-URL: Homepage, https://vmux.sdan.io
6
6
  Project-URL: Documentation, https://vmux.sdan.io
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vmux-cli"
7
- version = "0.5.4"
7
+ version = "0.6.0"
8
8
  description = "Run anything in the cloud. Replace uv run with vmux run."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -66,6 +66,12 @@ Issues = "https://github.com/sdan/vmux/issues"
66
66
  [tool.hatch.build.targets.wheel]
67
67
  packages = ["vmux"]
68
68
 
69
+ [tool.hatch.build.targets.sdist]
70
+ include = [
71
+ "vmux/**/*.py",
72
+ "vmux/**/*.md",
73
+ ]
74
+
69
75
  [tool.ruff]
70
76
  line-length = 100
71
77
  target-version = "py310"
@@ -0,0 +1,67 @@
1
+ ---
2
+ name: vmux
3
+ description: Deploy to vmux cloud compute. Use when user says "deploy", "vmux", "run in cloud", "preview URL", or wants to run commands on remote compute.
4
+ ---
5
+
6
+ # vmux - Cloud Compute in 5 Seconds
7
+
8
+ Run any command in the cloud. Close your laptop, keep running.
9
+
10
+ ## Setup
11
+
12
+ ```bash
13
+ vmux whoami # Check login status
14
+ vmux login # If needed
15
+ ```
16
+
17
+ ## vmux run
18
+
19
+ ```bash
20
+ vmux run [flags] <command>
21
+ ```
22
+
23
+ ### Flags
24
+
25
+ | Flag | Short | Description |
26
+ |------|-------|-------------|
27
+ | `--detach` | `-d` | Run in background, return job ID immediately |
28
+ | `--port <port>` | `-p` | Expose port for preview URL (can use multiple times) |
29
+ | `--preview` | | Auto-detect port from framework and expose it |
30
+ | `--env KEY=VAL` | `-e` | Set environment variable |
31
+ | `--runtime` | `-r` | Force runtime: `python`, `bun`, or `node` |
32
+
33
+ ### Flag Combinations
34
+
35
+ Flags can be combined: `-dp 8000` = detached + port 8000
36
+
37
+ ```bash
38
+ vmux run python script.py # Streams logs, blocks
39
+ vmux run -d python script.py # Detached, returns job ID
40
+ vmux run -p 8000 python server.py # Expose port 8000, get preview URL
41
+ vmux run -dp 8000 python server.py # Detached + port (most common for web)
42
+ vmux run -d --preview bun run dev # Auto-detect port from framework
43
+ vmux run -p 3000 -p 8000 npm run dev # Multiple ports
44
+ vmux run -e API_KEY=xxx python app.py # With env var
45
+ vmux run -r bun npm run dev # Force bun runtime
46
+ ```
47
+
48
+ ## After Deploy
49
+
50
+ Always give the user:
51
+ 1. The **preview URL** (if port exposed) - format: `https://<job-id>.purr.ge`
52
+ 2. The **job ID**
53
+ 3. How to monitor: `vmux logs -f <job-id>`
54
+ 4. How to stop: `vmux stop <job-id>`
55
+
56
+ ## Other Commands
57
+
58
+ ```bash
59
+ vmux ps # List running jobs
60
+ vmux logs <job-id> # Get logs
61
+ vmux logs -f <job-id> # Follow logs in real-time
62
+ vmux attach <job-id> # Interactive tmux session (Ctrl+B,D to detach)
63
+ vmux stop <job-id> # Kill job
64
+ vmux stop -a # Stop all running jobs
65
+ vmux debug <job-id> # Show tmux status and processes
66
+ vmux secret set KEY # Store secret in keychain
67
+ ```
@@ -1,6 +1,7 @@
1
1
  """CLI for vmux - run any command in the cloud."""
2
2
 
3
3
  import sys
4
+ from pathlib import Path
4
5
  import click
5
6
 
6
7
  from .client import TupClient
@@ -9,6 +10,56 @@ from .config import load_config, save_config
9
10
  from .core import run_command
10
11
 
11
12
 
13
+ def detect_framework_port() -> int:
14
+ """Auto-detect the default port based on framework files in current directory.
15
+
16
+ Returns:
17
+ Port number based on detected framework, or 8000 as fallback.
18
+ """
19
+ cwd = Path.cwd()
20
+
21
+ # Check for Vite (port 5173)
22
+ if any(cwd.glob("vite.config.*")):
23
+ return 5173
24
+
25
+ # Check for Next.js (port 3000)
26
+ if any(cwd.glob("next.config.*")):
27
+ return 3000
28
+
29
+ # Check package.json for hints
30
+ package_json = cwd / "package.json"
31
+ if package_json.exists():
32
+ try:
33
+ import json
34
+ pkg = json.loads(package_json.read_text())
35
+ deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
36
+
37
+ # Vite-based frameworks
38
+ if "vite" in deps:
39
+ return 5173
40
+ # Next.js
41
+ if "next" in deps:
42
+ return 3000
43
+ # Create React App / generic React
44
+ if "react-scripts" in deps:
45
+ return 3000
46
+ # Express / generic Node
47
+ if "express" in deps:
48
+ return 3000
49
+ # Bun default
50
+ if any(cwd.glob("bun.lockb")) or any(cwd.glob("bunfig.toml")):
51
+ return 3000
52
+ except Exception:
53
+ pass
54
+
55
+ # Python frameworks default to 8000
56
+ if any(cwd.glob("*.py")) or any(cwd.glob("requirements*.txt")) or any(cwd.glob("pyproject.toml")):
57
+ return 8000
58
+
59
+ # Default fallback
60
+ return 8000
61
+
62
+
12
63
  @click.group()
13
64
  def cli() -> None:
14
65
  """Run any command in the cloud."""
@@ -18,10 +69,11 @@ def cli() -> None:
18
69
  @cli.command()
19
70
  @click.option("--detach", "-d", is_flag=True, help="Return job ID without streaming logs")
20
71
  @click.option("--port", "-p", multiple=True, type=int, help="Port to expose for preview URL (can be used multiple times)")
21
- @click.option("--preview", is_flag=True, help="Expose port 8000 for preview URL (shorthand for -p 8000)")
72
+ @click.option("--preview", is_flag=True, help="Expose port for preview URL (auto-detects port from framework)")
22
73
  @click.option("--env", "-e", multiple=True, help="Environment variable (KEY=VALUE)")
74
+ @click.option("--runtime", "-r", type=click.Choice(["python", "bun", "node"]), help="Force runtime (auto-detected if not specified)")
23
75
  @click.argument("command", nargs=-1, required=True)
24
- def run(detach: bool, port: tuple[int, ...], preview: bool, env: tuple[str, ...], command: tuple[str, ...]) -> None:
76
+ def run(detach: bool, port: tuple[int, ...], preview: bool, env: tuple[str, ...], runtime: str | None, command: tuple[str, ...]) -> None:
25
77
  """Run a command in the cloud.
26
78
 
27
79
  \b
@@ -31,6 +83,8 @@ def run(detach: bool, port: tuple[int, ...], preview: bool, env: tuple[str, ...]
31
83
  vmux run --preview python server.py # Web server on :8000
32
84
  vmux run -p 8000 python server.py # Same as above
33
85
  vmux run -p 3000 -p 8000 npm run dev # Multiple ports
86
+ vmux run --preview bun run server.ts # Bun web server
87
+ vmux run -r bun npm run dev # Force Bun runtime
34
88
  """
35
89
  env_vars = {}
36
90
  for e in env:
@@ -49,19 +103,21 @@ def run(detach: bool, port: tuple[int, ...], preview: bool, env: tuple[str, ...]
49
103
  command.insert(0, f"{key}={value}")
50
104
  break
51
105
 
52
- # Combine --preview (default 8000) with explicit -p ports
106
+ # Combine --preview with explicit -p ports
107
+ # If --preview is used without -p, auto-detect port from framework
53
108
  ports = list(port)
54
- if preview and 8000 not in ports:
55
- ports.append(8000)
109
+ if preview and not ports:
110
+ detected_port = detect_framework_port()
111
+ ports.append(detected_port)
56
112
 
57
113
  try:
58
- run_command(" ".join(command), env_vars=env_vars or None, detach=detach, ports=ports)
114
+ run_command(" ".join(command), env_vars=env_vars or None, detach=detach, ports=ports, runtime=runtime)
59
115
  except Exception as e:
60
116
  error(str(e))
61
117
  sys.exit(1)
62
118
 
63
119
 
64
- def _list_jobs(limit: int) -> None:
120
+ def _list_jobs(limit: int, all_jobs: bool = False) -> None:
65
121
  """Shared implementation for ps/ls."""
66
122
  from datetime import datetime
67
123
 
@@ -69,8 +125,12 @@ def _list_jobs(limit: int) -> None:
69
125
  with TupClient(config) as client:
70
126
  jobs = client.list_jobs(limit=limit)
71
127
 
128
+ # Filter out completed/failed unless --all
129
+ if not all_jobs:
130
+ jobs = [j for j in jobs if j.get("status") in ("running", "pending")]
131
+
72
132
  if not jobs:
73
- warning("No jobs found.")
133
+ warning("No running jobs." if not all_jobs else "No jobs found.")
74
134
  return
75
135
 
76
136
  # Sort by created_at ascending (oldest first, newest at bottom)
@@ -119,28 +179,62 @@ def _list_jobs(limit: int) -> None:
119
179
 
120
180
  @cli.command(name="ps")
121
181
  @click.option("--limit", "-l", default=20, help="Number of jobs to show")
122
- def ps_cmd(limit: int) -> None:
182
+ @click.option("--all", "-a", "all_jobs", is_flag=True, help="Show all jobs including completed/failed")
183
+ def ps_cmd(limit: int, all_jobs: bool) -> None:
123
184
  """List running jobs."""
124
- _list_jobs(limit)
185
+ _list_jobs(limit, all_jobs)
125
186
 
126
187
 
127
- @cli.command(name="ls")
188
+ # Hidden alias for ps
189
+ @cli.command(name="ls", hidden=True)
128
190
  @click.option("--limit", "-l", default=20, help="Number of jobs to show")
129
- def ls_cmd(limit: int) -> None:
130
- """List running jobs (alias for ps)."""
131
- _list_jobs(limit)
191
+ @click.option("--all", "-a", "all_jobs", is_flag=True, help="Show all jobs including completed/failed")
192
+ def ls_cmd(limit: int, all_jobs: bool) -> None:
193
+ """List running jobs."""
194
+ _list_jobs(limit, all_jobs)
132
195
 
133
196
 
134
197
  @cli.command()
135
- @click.argument("job_id")
136
- def stop(job_id: str) -> None:
137
- """Stop a running job."""
198
+ @click.argument("job_ids", nargs=-1)
199
+ @click.option("--all", "-a", "stop_all", is_flag=True, help="Stop all running jobs")
200
+ def stop(job_ids: tuple[str, ...], stop_all: bool) -> None:
201
+ """Stop one or more jobs.
202
+
203
+ \b
204
+ Examples:
205
+ vmux stop abc123 # Stop one job
206
+ vmux stop abc123 def456 # Stop multiple jobs
207
+ vmux stop -a # Stop all running jobs
208
+ """
138
209
  config = load_config()
139
210
  with TupClient(config) as client:
140
- if client.stop_job(job_id):
141
- success(f"Stopped {job_id}")
211
+ if stop_all:
212
+ jobs = client.list_jobs(limit=100)
213
+ running = [j for j in jobs if j.get("status") == "running"]
214
+ if not running:
215
+ warning("No running jobs to stop.")
216
+ return
217
+ job_ids = tuple(j["job_id"] for j in running)
218
+ console.print(f"Stopping {len(job_ids)} running jobs...")
219
+ elif not job_ids:
220
+ error("Specify job ID(s) or use --all")
221
+ sys.exit(1)
222
+
223
+ stopped = 0
224
+ for job_id in job_ids:
225
+ try:
226
+ if client.stop_job(job_id):
227
+ console.print(f" [green]✓[/green] {job_id}")
228
+ stopped += 1
229
+ else:
230
+ console.print(f" [red]✗[/red] {job_id} (failed)")
231
+ except Exception as e:
232
+ console.print(f" [red]✗[/red] {job_id} ({e})")
233
+
234
+ if stopped > 0:
235
+ success(f"Stopped {stopped} job(s)")
142
236
  else:
143
- error("Failed to stop job")
237
+ error("No jobs stopped")
144
238
  sys.exit(1)
145
239
 
146
240
 
@@ -236,6 +330,96 @@ def logout() -> None:
236
330
  success("Logged out.")
237
331
 
238
332
 
333
+ @cli.command()
334
+ def status() -> None:
335
+ """Show global capacity status."""
336
+ config = load_config()
337
+ if not config.auth_token:
338
+ error("Not logged in. Run: vmux login")
339
+ sys.exit(1)
340
+
341
+ with TupClient(config) as client:
342
+ try:
343
+ data = client.get_status()
344
+ current = data.get("current_jobs", 0)
345
+ max_jobs = data.get("max_jobs", 90)
346
+ percent = data.get("capacity_percent", 0)
347
+
348
+ # Color based on capacity
349
+ if percent < 50:
350
+ color = "green"
351
+ elif percent < 80:
352
+ color = "yellow"
353
+ else:
354
+ color = "red"
355
+
356
+ console.print()
357
+ console.print(f" [dim]Capacity:[/dim] [{color}]{current}[/{color}] / {max_jobs} jobs ({percent}%)")
358
+ console.print()
359
+ except Exception as e:
360
+ error(f"Failed to get status: {e}")
361
+ sys.exit(1)
362
+
363
+
364
+ @cli.command()
365
+ @click.argument("job_id")
366
+ def debug(job_id: str) -> None:
367
+ """Debug a job - show tmux status, processes, and recent logs."""
368
+ config = load_config()
369
+ if not config.auth_token:
370
+ error("Not logged in. Run: vmux login")
371
+ sys.exit(1)
372
+
373
+ with TupClient(config) as client:
374
+ try:
375
+ data = client.debug_job(job_id)
376
+ console.print()
377
+ for cmd, output in data.items():
378
+ console.print(f"[cyan]$ {cmd}[/cyan]")
379
+ console.print(output)
380
+ console.print()
381
+ except Exception as e:
382
+ error(f"Debug failed: {e}")
383
+ sys.exit(1)
384
+
385
+
386
+ @cli.command()
387
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation")
388
+ def prune(force: bool) -> None:
389
+ """Remove completed/failed jobs from history.
390
+
391
+ \b
392
+ Examples:
393
+ vmux prune # Remove old jobs (asks for confirmation)
394
+ vmux prune -f # Remove without confirmation
395
+ """
396
+ config = load_config()
397
+ with TupClient(config) as client:
398
+ jobs = client.list_jobs(limit=200)
399
+ stale = [j for j in jobs if j.get("status") in ("completed", "failed")]
400
+
401
+ if not stale:
402
+ warning("No completed/failed jobs to prune.")
403
+ return
404
+
405
+ console.print(f"Found {len(stale)} completed/failed jobs.")
406
+ if not force:
407
+ if not click.confirm("Remove from history?"):
408
+ console.print("Cancelled.")
409
+ return
410
+
411
+ removed = 0
412
+ for job in stale:
413
+ try:
414
+ # Stop will mark as failed and clean up
415
+ client.stop_job(job["job_id"])
416
+ removed += 1
417
+ except Exception:
418
+ pass # Already gone or can't remove
419
+
420
+ success(f"Pruned {removed} jobs")
421
+
422
+
239
423
  @cli.command()
240
424
  def whoami() -> None:
241
425
  """Show current user."""
@@ -389,6 +573,65 @@ def secret_rm(key: str) -> None:
389
573
  error(f"Secret '{key}' not found")
390
574
 
391
575
 
576
+ @cli.group()
577
+ def claude() -> None:
578
+ """Claude Code integration."""
579
+ pass
580
+
581
+
582
+ @claude.command(name="install")
583
+ def claude_install() -> None:
584
+ """Install vmux skill for Claude Code.
585
+
586
+ Copies the vmux skill to ~/.claude/skills/vmux/ so Claude Code
587
+ can use vmux commands via /vmux or natural language.
588
+ """
589
+ import shutil
590
+
591
+ # Find the skill file bundled with vmux
592
+ skill_source = Path(__file__).parent.parent / "claude" / "skills" / "vmux" / "SKILL.md"
593
+
594
+ # Fallback: check if installed as package
595
+ if not skill_source.exists():
596
+ import importlib.resources
597
+ try:
598
+ # Python 3.9+
599
+ files = importlib.resources.files("vmux")
600
+ skill_source = Path(str(files / "claude" / "skills" / "vmux" / "SKILL.md"))
601
+ except Exception:
602
+ pass
603
+
604
+ if not skill_source.exists():
605
+ error("Skill file not found. Try reinstalling vmux.")
606
+ sys.exit(1)
607
+
608
+ # Install to ~/.claude/skills/vmux/
609
+ skill_dest = Path.home() / ".claude" / "skills" / "vmux"
610
+ skill_dest.mkdir(parents=True, exist_ok=True)
611
+
612
+ dest_file = skill_dest / "SKILL.md"
613
+ shutil.copy(skill_source, dest_file)
614
+
615
+ success(f"Installed vmux skill to {dest_file}")
616
+ console.print()
617
+ console.print("[dim]Claude Code will now understand vmux commands.[/dim]")
618
+ console.print("[dim]Try: 'deploy this with vmux' or '/vmux'[/dim]")
619
+
620
+
621
+ @claude.command(name="uninstall")
622
+ def claude_uninstall() -> None:
623
+ """Remove vmux skill from Claude Code."""
624
+ import shutil
625
+
626
+ skill_dir = Path.home() / ".claude" / "skills" / "vmux"
627
+
628
+ if skill_dir.exists():
629
+ shutil.rmtree(skill_dir)
630
+ success("Removed vmux skill from Claude Code")
631
+ else:
632
+ warning("vmux skill not installed")
633
+
634
+
392
635
  def main() -> None:
393
636
  cli()
394
637
 
@@ -71,6 +71,7 @@ class TupClient:
71
71
  env_vars: dict[str, str] | None = None,
72
72
  editables: list[str] | None = None,
73
73
  ports: list[int] | None = None,
74
+ runtime: str | None = None,
74
75
  on_upload_start: Callable[[int, bool], None] | None = None, # (size, is_r2) -> None
75
76
  ) -> Iterator[dict]:
76
77
  """Run a command and stream logs.
@@ -81,6 +82,7 @@ class TupClient:
81
82
  env_vars: Environment variables
82
83
  editables: List of editable package names (for PYTHONPATH)
83
84
  ports: Ports to expose for preview URLs (default: [8000])
85
+ runtime: Force runtime ("python", "bun", "node") - auto-detected if None
84
86
 
85
87
  Yields events:
86
88
  {"job_id": "..."} - First event with job ID
@@ -109,13 +111,15 @@ class TupClient:
109
111
  if not use_r2:
110
112
  # Small bundle: base64 encode and send inline
111
113
  bundle_b64 = base64.b64encode(bundle_data).decode("ascii")
112
- payload = {
114
+ payload: dict = {
113
115
  "command": command,
114
116
  "bundle": bundle_b64,
115
117
  "env_vars": merged_env,
116
118
  "editables": editables or [],
117
119
  "ports": ports or [],
118
120
  }
121
+ if runtime:
122
+ payload["runtime"] = runtime
119
123
  if DEBUG:
120
124
  print(f"[DEBUG] Using inline upload ({bundle_size} bytes raw, {len(bundle_b64)} chars base64)")
121
125
  else:
@@ -130,6 +134,8 @@ class TupClient:
130
134
  "editables": editables or [],
131
135
  "ports": ports or [],
132
136
  }
137
+ if runtime:
138
+ payload["runtime"] = runtime
133
139
 
134
140
  if DEBUG:
135
141
  print(f"[DEBUG] Payload size: {len(json.dumps(payload))} bytes")
@@ -182,6 +188,12 @@ class TupClient:
182
188
  response.raise_for_status()
183
189
  return response.json().get("stopped", False)
184
190
 
191
+ def purge_job(self, job_id: str) -> bool:
192
+ """Permanently delete a job from history."""
193
+ response = self._client.delete(f"/jobs/{job_id}?purge=true", headers=self._headers())
194
+ response.raise_for_status()
195
+ return response.json().get("purged", False)
196
+
185
197
  def get_logs(self, job_id: str) -> str:
186
198
  """Get job logs."""
187
199
  response = self._client.get(f"/jobs/{job_id}/logs", headers=self._headers())
@@ -193,3 +205,15 @@ class TupClient:
193
205
  response = self._client.get("/usage", headers=self._headers())
194
206
  response.raise_for_status()
195
207
  return response.json()
208
+
209
+ def get_status(self) -> dict:
210
+ """Get global capacity status."""
211
+ response = self._client.get("/status", headers=self._headers())
212
+ response.raise_for_status()
213
+ return response.json()
214
+
215
+ def debug_job(self, job_id: str) -> dict:
216
+ """Get debug info for a job (tmux status, processes, logs)."""
217
+ response = self._client.get(f"/jobs/{job_id}/debug", headers=self._headers())
218
+ response.raise_for_status()
219
+ return response.json()
@@ -1,6 +1,7 @@
1
1
  """Core functionality for vmux."""
2
2
 
3
3
  import os
4
+ import re
4
5
  import signal
5
6
  import sys
6
7
  import time
@@ -63,6 +64,7 @@ def run_command(
63
64
  detach: bool = False,
64
65
  ports: list[int] | None = None,
65
66
  directory: Path | str | None = None,
67
+ runtime: str | None = None,
66
68
  ) -> str:
67
69
  """Run a command in the cloud.
68
70
 
@@ -72,6 +74,7 @@ def run_command(
72
74
  detach: Return immediately after job starts (don't stream logs)
73
75
  ports: Ports to expose for preview URLs (empty = no preview)
74
76
  directory: Working directory (default: current directory)
77
+ runtime: Force runtime ("python", "bun", "node") - auto-detected if None
75
78
 
76
79
  Returns:
77
80
  job_id
@@ -79,6 +82,23 @@ def run_command(
79
82
  ports = ports or []
80
83
  t_start = time.time()
81
84
  directory = Path(directory) if directory else Path.cwd()
85
+ env_vars = env_vars or {}
86
+
87
+ # Auto-inject PORT env var for JS runtimes (Bun, Node)
88
+ # This matches Vercel/Render behavior where PORT is set automatically
89
+ if ports and "PORT" not in env_vars:
90
+ from .deps import is_bun_project
91
+ parts = command.split() if command and command.strip() else []
92
+ first_word = parts[0] if parts else ""
93
+ js_commands = ("node", "bun", "npm", "npx", "yarn", "pnpm", "deno")
94
+ is_js_runtime = (
95
+ runtime in ("bun", "node")
96
+ or first_word in js_commands
97
+ or is_bun_project(directory)
98
+ )
99
+ if is_js_runtime:
100
+ env_vars["PORT"] = str(ports[0])
101
+ _debug(f"Injected PORT={ports[0]} for JS runtime")
82
102
 
83
103
  # Package project (detects editables automatically)
84
104
  _debug("Starting package()")
@@ -96,13 +116,11 @@ def run_command(
96
116
 
97
117
  config = load_config()
98
118
 
99
- # Merge secrets from keychain with passed env vars (passed takes precedence)
119
+ # Merge secrets from keychain with env vars (env_vars takes precedence)
100
120
  _debug("Loading secrets")
101
- merged_env = get_secrets()
102
- if env_vars:
103
- merged_env.update(env_vars)
104
- env_vars = merged_env or None
105
- _debug(f"Secrets loaded: {len(merged_env)} keys")
121
+ secrets = get_secrets()
122
+ merged_env = {**secrets, **env_vars} # env_vars overrides secrets
123
+ _debug(f"Secrets loaded: {len(secrets)} keys")
106
124
 
107
125
  _debug(f"Total prep time: {time.time() - t_start:.2f}s")
108
126
  _debug("Connecting to API...")
@@ -135,20 +153,15 @@ def run_command(
135
153
  for event in client.run(
136
154
  command=command,
137
155
  bundle_data=bundle.data,
138
- env_vars=env_vars,
156
+ env_vars=merged_env or None,
139
157
  editables=bundle.editables,
140
158
  ports=ports,
159
+ runtime=runtime,
141
160
  on_upload_start=on_upload_start,
142
161
  ):
143
162
  if "job_id" in event:
144
163
  job_id = event["job_id"]
145
- # In detach mode without ports, return immediately
146
- if detach and not ports:
147
- deploy_time = time.time() - t_bundled
148
- stats = f"[dim]({_format_bytes(bundle_size)} • {_format_duration(deploy_time)})[/dim]"
149
- success(f"Job started: {job_id} {stats}")
150
- console.print(f"[blue]Follow: vmux logs -f {job_id}[/blue]")
151
- return job_id
164
+ # Don't return early - wait for "running" status so setup completes
152
165
 
153
166
  # Preview URLs arrive early (before "running") - show them immediately
154
167
  if "preview_urls" in event:
@@ -199,7 +212,10 @@ def run_command(
199
212
  sys.exit(1)
200
213
 
201
214
  elif "log" in event:
202
- console.print(event["log"], end="")
215
+ # Filter out terminal focus escape sequences (^[[I, ^[[O)
216
+ log_text = re.sub(r'\x1b\[[IO]', '', event["log"])
217
+ if log_text:
218
+ console.print(log_text, end="")
203
219
 
204
220
  elif "error" in event:
205
221
  error(event["error"])
@@ -57,6 +57,17 @@ def is_uv_project(directory: Path) -> bool:
57
57
  return (directory / "pyproject.toml").exists() and (directory / "uv.lock").exists()
58
58
 
59
59
 
60
+ def is_bun_project(directory: Path) -> bool:
61
+ """Check if directory is a Bun project (has bun.lockb or bunfig.toml)."""
62
+ if not directory.is_dir():
63
+ return False
64
+ return (
65
+ (directory / "bun.lockb").exists()
66
+ or (directory / "bun.lock").exists()
67
+ or (directory / "bunfig.toml").exists()
68
+ )
69
+
70
+
60
71
  def parse_imports(script_path: Path) -> list[str]:
61
72
  """Extract third-party imports from a Python script."""
62
73
  try:
@@ -135,12 +146,17 @@ def find_editable(pkg_name: str) -> EditablePackage | None:
135
146
  )
136
147
 
137
148
 
138
- def detect_script_deps(script_path: Path) -> ScriptDeps:
149
+ def detect_script_deps(script_path: Path, verbose: bool = True) -> ScriptDeps:
139
150
  """Detect dependencies for a standalone script.
140
151
 
141
152
  Parses imports, finds editable installs.
142
153
  The container will install dependencies from the editable's uv.lock/pyproject.toml.
143
154
  """
155
+ from . import ui
156
+
157
+ if verbose:
158
+ ui.dim(f"→ Scanning {script_path.name} imports...")
159
+
144
160
  imports = set(parse_imports(script_path))
145
161
 
146
162
  editables: list[EditablePackage] = []
@@ -154,6 +170,9 @@ def detect_script_deps(script_path: Path) -> ScriptDeps:
154
170
  return find_cache[name]
155
171
 
156
172
  # Direct editables imported by the script
173
+ if verbose and imports:
174
+ ui.dim(f"→ Checking {len(imports)} imports for editables...")
175
+
157
176
  for pkg_name in sorted(imports):
158
177
  editable = cached_find(pkg_name)
159
178
  if editable and all(e.name != editable.name for e in editables):
@@ -168,6 +187,9 @@ def detect_script_deps(script_path: Path) -> ScriptDeps:
168
187
  continue
169
188
  scanned_paths.add(editable_path)
170
189
 
190
+ if verbose:
191
+ ui.dim(f"→ Scanning {editable.name} for transitive deps...")
192
+
171
193
  for dep_name in sorted(scan_imports_in_tree(editable_path)):
172
194
  dep_editable = cached_find(dep_name)
173
195
  if dep_editable and all(e.name != dep_editable.name for e in editables):