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.
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/PKG-INFO +1 -1
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/pyproject.toml +7 -1
- vmux_cli-0.6.0/vmux/claude/skills/vmux/SKILL.md +67 -0
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/vmux/cli.py +263 -20
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/vmux/client.py +25 -1
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/vmux/core.py +31 -15
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/vmux/deps.py +23 -1
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/vmux/packager.py +3 -3
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/vmux/terminal.py +35 -7
- vmux_cli-0.5.4/tests/__init__.py +0 -1
- vmux_cli-0.5.4/tests/benchmark_bundle_delivery.py +0 -374
- vmux_cli-0.5.4/tests/benchmark_latency.py +0 -179
- vmux_cli-0.5.4/tests/test_config.py +0 -75
- vmux_cli-0.5.4/tests/test_packager.py +0 -105
- vmux_cli-0.5.4/uv.lock +0 -934
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/.gitignore +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/README.md +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/vmux/__init__.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/vmux/auth.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/vmux/config.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/vmux/runner.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/vmux/types.py +0 -0
- {vmux_cli-0.5.4 → vmux_cli-0.6.0}/vmux/ui.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vmux-cli"
|
|
7
|
-
version = "0.
|
|
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
|
|
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
|
|
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
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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("
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
141
|
-
|
|
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("
|
|
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
|
|
119
|
+
# Merge secrets from keychain with env vars (env_vars takes precedence)
|
|
100
120
|
_debug("Loading secrets")
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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=
|
|
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
|
-
#
|
|
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
|
-
|
|
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):
|