vmux-cli 0.5.4__tar.gz → 0.5.5__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.5.5
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.5.5"
8
8
  description = "Run anything in the cloud. Replace uv run with vmux run."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -827,7 +827,7 @@ wheels = [
827
827
 
828
828
  [[package]]
829
829
  name = "vmux-cli"
830
- version = "0.5.3"
830
+ version = "0.5.4"
831
831
  source = { editable = "." }
832
832
  dependencies = [
833
833
  { name = "click" },
@@ -61,7 +61,7 @@ def run(detach: bool, port: tuple[int, ...], preview: bool, env: tuple[str, ...]
61
61
  sys.exit(1)
62
62
 
63
63
 
64
- def _list_jobs(limit: int) -> None:
64
+ def _list_jobs(limit: int, all_jobs: bool = False) -> None:
65
65
  """Shared implementation for ps/ls."""
66
66
  from datetime import datetime
67
67
 
@@ -69,8 +69,12 @@ def _list_jobs(limit: int) -> None:
69
69
  with TupClient(config) as client:
70
70
  jobs = client.list_jobs(limit=limit)
71
71
 
72
+ # Filter out completed/failed unless --all
73
+ if not all_jobs:
74
+ jobs = [j for j in jobs if j.get("status") in ("running", "pending")]
75
+
72
76
  if not jobs:
73
- warning("No jobs found.")
77
+ warning("No running jobs." if not all_jobs else "No jobs found.")
74
78
  return
75
79
 
76
80
  # Sort by created_at ascending (oldest first, newest at bottom)
@@ -119,28 +123,62 @@ def _list_jobs(limit: int) -> None:
119
123
 
120
124
  @cli.command(name="ps")
121
125
  @click.option("--limit", "-l", default=20, help="Number of jobs to show")
122
- def ps_cmd(limit: int) -> None:
126
+ @click.option("--all", "-a", "all_jobs", is_flag=True, help="Show all jobs including completed/failed")
127
+ def ps_cmd(limit: int, all_jobs: bool) -> None:
123
128
  """List running jobs."""
124
- _list_jobs(limit)
129
+ _list_jobs(limit, all_jobs)
125
130
 
126
131
 
127
- @cli.command(name="ls")
132
+ # Hidden alias for ps
133
+ @cli.command(name="ls", hidden=True)
128
134
  @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)
135
+ @click.option("--all", "-a", "all_jobs", is_flag=True, help="Show all jobs including completed/failed")
136
+ def ls_cmd(limit: int, all_jobs: bool) -> None:
137
+ """List running jobs."""
138
+ _list_jobs(limit, all_jobs)
132
139
 
133
140
 
134
141
  @cli.command()
135
- @click.argument("job_id")
136
- def stop(job_id: str) -> None:
137
- """Stop a running job."""
142
+ @click.argument("job_ids", nargs=-1)
143
+ @click.option("--all", "-a", "stop_all", is_flag=True, help="Stop all running jobs")
144
+ def stop(job_ids: tuple[str, ...], stop_all: bool) -> None:
145
+ """Stop one or more jobs.
146
+
147
+ \b
148
+ Examples:
149
+ vmux stop abc123 # Stop one job
150
+ vmux stop abc123 def456 # Stop multiple jobs
151
+ vmux stop -a # Stop all running jobs
152
+ """
138
153
  config = load_config()
139
154
  with TupClient(config) as client:
140
- if client.stop_job(job_id):
141
- success(f"Stopped {job_id}")
155
+ if stop_all:
156
+ jobs = client.list_jobs(limit=100)
157
+ running = [j for j in jobs if j.get("status") == "running"]
158
+ if not running:
159
+ warning("No running jobs to stop.")
160
+ return
161
+ job_ids = tuple(j["job_id"] for j in running)
162
+ console.print(f"Stopping {len(job_ids)} running jobs...")
163
+ elif not job_ids:
164
+ error("Specify job ID(s) or use --all")
165
+ sys.exit(1)
166
+
167
+ stopped = 0
168
+ for job_id in job_ids:
169
+ try:
170
+ if client.stop_job(job_id):
171
+ console.print(f" [green]✓[/green] {job_id}")
172
+ stopped += 1
173
+ else:
174
+ console.print(f" [red]✗[/red] {job_id} (failed)")
175
+ except Exception as e:
176
+ console.print(f" [red]✗[/red] {job_id} ({e})")
177
+
178
+ if stopped > 0:
179
+ success(f"Stopped {stopped} job(s)")
142
180
  else:
143
- error("Failed to stop job")
181
+ error("No jobs stopped")
144
182
  sys.exit(1)
145
183
 
146
184
 
@@ -236,6 +274,74 @@ def logout() -> None:
236
274
  success("Logged out.")
237
275
 
238
276
 
277
+ @cli.command()
278
+ def status() -> None:
279
+ """Show global capacity status."""
280
+ config = load_config()
281
+ if not config.auth_token:
282
+ error("Not logged in. Run: vmux login")
283
+ sys.exit(1)
284
+
285
+ with TupClient(config) as client:
286
+ try:
287
+ data = client.get_status()
288
+ current = data.get("current_jobs", 0)
289
+ max_jobs = data.get("max_jobs", 90)
290
+ percent = data.get("capacity_percent", 0)
291
+
292
+ # Color based on capacity
293
+ if percent < 50:
294
+ color = "green"
295
+ elif percent < 80:
296
+ color = "yellow"
297
+ else:
298
+ color = "red"
299
+
300
+ console.print()
301
+ console.print(f" [dim]Capacity:[/dim] [{color}]{current}[/{color}] / {max_jobs} jobs ({percent}%)")
302
+ console.print()
303
+ except Exception as e:
304
+ error(f"Failed to get status: {e}")
305
+ sys.exit(1)
306
+
307
+
308
+ @cli.command()
309
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation")
310
+ def prune(force: bool) -> None:
311
+ """Remove completed/failed jobs from history.
312
+
313
+ \b
314
+ Examples:
315
+ vmux prune # Remove old jobs (asks for confirmation)
316
+ vmux prune -f # Remove without confirmation
317
+ """
318
+ config = load_config()
319
+ with TupClient(config) as client:
320
+ jobs = client.list_jobs(limit=200)
321
+ stale = [j for j in jobs if j.get("status") in ("completed", "failed")]
322
+
323
+ if not stale:
324
+ warning("No completed/failed jobs to prune.")
325
+ return
326
+
327
+ console.print(f"Found {len(stale)} completed/failed jobs.")
328
+ if not force:
329
+ if not click.confirm("Remove from history?"):
330
+ console.print("Cancelled.")
331
+ return
332
+
333
+ removed = 0
334
+ for job in stale:
335
+ try:
336
+ # Stop will mark as failed and clean up
337
+ client.stop_job(job["job_id"])
338
+ removed += 1
339
+ except Exception:
340
+ pass # Already gone or can't remove
341
+
342
+ success(f"Pruned {removed} jobs")
343
+
344
+
239
345
  @cli.command()
240
346
  def whoami() -> None:
241
347
  """Show current user."""
@@ -182,6 +182,12 @@ class TupClient:
182
182
  response.raise_for_status()
183
183
  return response.json().get("stopped", False)
184
184
 
185
+ def purge_job(self, job_id: str) -> bool:
186
+ """Permanently delete a job from history."""
187
+ response = self._client.delete(f"/jobs/{job_id}?purge=true", headers=self._headers())
188
+ response.raise_for_status()
189
+ return response.json().get("purged", False)
190
+
185
191
  def get_logs(self, job_id: str) -> str:
186
192
  """Get job logs."""
187
193
  response = self._client.get(f"/jobs/{job_id}/logs", headers=self._headers())
@@ -193,3 +199,9 @@ class TupClient:
193
199
  response = self._client.get("/usage", headers=self._headers())
194
200
  response.raise_for_status()
195
201
  return response.json()
202
+
203
+ def get_status(self) -> dict:
204
+ """Get global capacity status."""
205
+ response = self._client.get("/status", headers=self._headers())
206
+ response.raise_for_status()
207
+ return response.json()
@@ -135,12 +135,17 @@ def find_editable(pkg_name: str) -> EditablePackage | None:
135
135
  )
136
136
 
137
137
 
138
- def detect_script_deps(script_path: Path) -> ScriptDeps:
138
+ def detect_script_deps(script_path: Path, verbose: bool = True) -> ScriptDeps:
139
139
  """Detect dependencies for a standalone script.
140
140
 
141
141
  Parses imports, finds editable installs.
142
142
  The container will install dependencies from the editable's uv.lock/pyproject.toml.
143
143
  """
144
+ from . import ui
145
+
146
+ if verbose:
147
+ ui.dim(f"→ Scanning {script_path.name} imports...")
148
+
144
149
  imports = set(parse_imports(script_path))
145
150
 
146
151
  editables: list[EditablePackage] = []
@@ -154,6 +159,9 @@ def detect_script_deps(script_path: Path) -> ScriptDeps:
154
159
  return find_cache[name]
155
160
 
156
161
  # Direct editables imported by the script
162
+ if verbose and imports:
163
+ ui.dim(f"→ Checking {len(imports)} imports for editables...")
164
+
157
165
  for pkg_name in sorted(imports):
158
166
  editable = cached_find(pkg_name)
159
167
  if editable and all(e.name != editable.name for e in editables):
@@ -168,6 +176,9 @@ def detect_script_deps(script_path: Path) -> ScriptDeps:
168
176
  continue
169
177
  scanned_paths.add(editable_path)
170
178
 
179
+ if verbose:
180
+ ui.dim(f"→ Scanning {editable.name} for transitive deps...")
181
+
171
182
  for dep_name in sorted(scan_imports_in_tree(editable_path)):
172
183
  dep_editable = cached_find(dep_name)
173
184
  if dep_editable and all(e.name != dep_editable.name for e in editables):
@@ -48,8 +48,7 @@ def print_attach_header(job_id: str) -> None:
48
48
  print(f"{c.CYAN}{c.BOLD} VMUX ATTACH: {c.GREEN}{job_id}{c.RESET}")
49
49
  print(f"{c.CYAN}{c.BOLD}{'=' * width}{c.RESET}")
50
50
  print()
51
- print(f"{c.DIM} Connecting to cloud container...{c.RESET}")
52
- print()
51
+ print(f"{c.DIM} Connecting to cloud container...{c.RESET}", end="", flush=True)
53
52
 
54
53
 
55
54
  def print_attach_connected(job_id: str) -> None:
@@ -133,8 +132,28 @@ async def run_attach(job_id: str, config: "VmuxConfig") -> None:
133
132
  ws_url,
134
133
  additional_headers={"Authorization": f"Bearer {config.auth_token}"},
135
134
  ) as ws:
136
- # Wait for connected message
137
- msg = await asyncio.wait_for(ws.recv(), timeout=10.0)
135
+ # Wait for connected message with spinner
136
+ spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
137
+ frame_idx = 0
138
+
139
+ async def show_spinner() -> None:
140
+ nonlocal frame_idx
141
+ while True:
142
+ print(f"\r{Colors.DIM} Connecting to cloud container... {spinner_frames[frame_idx]}{Colors.RESET}", end="", flush=True)
143
+ frame_idx = (frame_idx + 1) % len(spinner_frames)
144
+ await asyncio.sleep(0.1)
145
+
146
+ spinner_task = asyncio.create_task(show_spinner())
147
+ try:
148
+ msg = await asyncio.wait_for(ws.recv(), timeout=30.0) # Increased timeout for cold containers
149
+ finally:
150
+ spinner_task.cancel()
151
+ try:
152
+ await spinner_task
153
+ except asyncio.CancelledError:
154
+ pass
155
+ print("\r" + " " * 50 + "\r", end="") # Clear spinner line
156
+
138
157
  data = json.loads(msg)
139
158
 
140
159
  if data.get("type") == "error":
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes