pow-cli 0.2.0rc1__tar.gz → 0.2.0rc2__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.
Files changed (58) hide show
  1. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/CHANGELOG.md +25 -0
  2. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/PKG-INFO +1 -1
  3. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/cli/asset.py +0 -1
  4. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/cli/init.py +19 -28
  5. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/cli/lint.py +0 -2
  6. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/cli/main.py +4 -2
  7. pow_cli-0.2.0rc2/pow_cli/cli/ros.py +155 -0
  8. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/cli/run.py +0 -2
  9. pow_cli-0.2.0rc2/pow_cli/cli/sim.py +67 -0
  10. pow_cli-0.2.0rc2/pow_cli/common/prompt.py +136 -0
  11. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/core/models/pow_config.py +74 -11
  12. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/core/ros_manager.py +77 -129
  13. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/core/runner.py +113 -13
  14. pow_cli-0.2.0rc2/pow_cli/data/pow.template.toml +19 -0
  15. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/docker/Dockerfile.simros_jazzy +99 -13
  16. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pyproject.toml +6 -1
  17. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/tests/test_config.py +69 -13
  18. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/tests/test_init_cmd.py +30 -7
  19. pow_cli-0.2.0rc2/tests/test_prompt.py +198 -0
  20. pow_cli-0.2.0rc2/tests/test_ros_cmd.py +154 -0
  21. pow_cli-0.2.0rc2/tests/test_ros_manager.py +268 -0
  22. pow_cli-0.2.0rc2/tests/test_runner.py +299 -0
  23. pow_cli-0.2.0rc2/tests/test_sim_cmd.py +71 -0
  24. pow_cli-0.2.0rc1/.vscode/launch.json +0 -49
  25. pow_cli-0.2.0rc1/.vscode/settings.json +0 -673
  26. pow_cli-0.2.0rc1/.vscode/tasks.json +0 -14
  27. pow_cli-0.2.0rc1/pow_cli/cli/ros.py +0 -41
  28. pow_cli-0.2.0rc1/pow_cli/data/pow.template.toml +0 -28
  29. pow_cli-0.2.0rc1/pow_cli/docker/Dockerfile.simros_humble +0 -200
  30. pow_cli-0.2.0rc1/tests/test_ros_manager.py +0 -113
  31. pow_cli-0.2.0rc1/tests/test_runner.py +0 -88
  32. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/.gitignore +0 -0
  33. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/LICENSE +0 -0
  34. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/NOTICE +0 -0
  35. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/README.md +0 -0
  36. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/__init__.py +0 -0
  37. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/cli/__init__.py +0 -0
  38. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/cli/check.py +0 -0
  39. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/cli/python.py +0 -0
  40. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/common/__init__.py +0 -0
  41. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/common/utils.py +0 -0
  42. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/core/__init__.py +0 -0
  43. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/core/asset_manager.py +0 -0
  44. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/core/initializer.py +0 -0
  45. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/core/linter.py +0 -0
  46. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/core/models/asset_profile.py +0 -0
  47. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/core/models/system_config.py +0 -0
  48. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/data/asset-registry/isaacsim_assets_5_1_0.toml +0 -0
  49. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/data/asset-registry/omniverse_assets.toml +0 -0
  50. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/data/example/asset-profile.example.toml +0 -0
  51. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/data/gitignore.template +0 -0
  52. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/data/system.template.toml +0 -0
  53. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/pow_cli/main.py +0 -0
  54. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/scripts/check_assets_urls.py +0 -0
  55. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/tests/test_check_cmd.py +0 -0
  56. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/tests/test_initializer.py +0 -0
  57. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/tests/test_verify_isaacsim.py +0 -0
  58. {pow_cli-0.2.0rc1 → pow_cli-0.2.0rc2}/tests/test_version.py +0 -0
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to the `pow-cli` package will be documented in this file.
4
4
 
5
+ ## [0.2.0-rc.2] - 2026-07-28
6
+
7
+ ### Added
8
+
9
+ - **`pow sim`** — run Isaac Sim from any directory, with no project required: no `pyproject.toml` check and `pow.toml` is never read. Launches the default version (`5.1.0`, override with `-v/--version`) and forwards raw arguments straight to `isaac-sim.sh` (`pow sim -- --no-window`). The ROS 2 bridge environment is loaded by default (`--ros jazzy`); pass `--no-ros` to launch with the inherited environment instead
10
+ - **`pow ros build`** — build the custom image from `ros_dockerfile` without re-running `pow init`; builds the `pow_simros_jazzy` base image first if it is missing, and supports `--no-cache`. `pow ros` is now a command group (bare `pow ros [args...]` usage is unchanged; `pow ros launch` is the explicit form)
11
+ - **`ros_bridge` config in `pow.toml`** — choose which Isaac Sim internal ROS 2 bridge libs to load (`jazzy` or `humble`, default `jazzy`), i.e. which `exts/isaacsim.ros2.bridge/<distro>/lib` is added to `LD_LIBRARY_PATH` when `enable_ros = true`. Overridable per profile. Previously the bridge was inferred from the host Ubuntu version with no way to override it; an unsupported value is now rejected with a clear error
12
+ - **Tab completion for the `pow init` ROS workspace path** — step 6's "Path to clone IsaacSim-ros_workspaces" prompt now completes filesystem paths on <kbd>Tab</kbd> like a shell: unique directories complete inline (no stray trailing space, so the next segment can be typed straight away), ambiguous prefixes list candidates, and `~` is kept in the stored path. Trailing separators and whitespace are trimmed before the value is written to `pow.toml`
13
+
14
+ ### Changed
15
+
16
+ - **Breaking (vs 0.2.0-rc.1):** the `ros_container_name` config key was renamed to `ros_docker_image`. The container name is no longer configured directly — it is derived from the image name (`/` and `:` replaced with `_`), so default setups now get a container named `pow_simros_jazzy` instead of `pow_simros`
17
+ - **Breaking (vs 0.2.0-rc.1):** ROS 2 Humble support was removed — the ROS workspace and docker integration now support Jazzy only (`Dockerfile.simros_humble` deleted). Isaac Sim itself still runs on Ubuntu 22.04 and 24.04; Jazzy workspaces build on both
18
+ - **`pow init` no longer builds the Isaac Sim ROS workspace.** Step 6 only clones `IsaacSim-ros_workspaces`; the ROS 2 bridge comes from Isaac Sim's own prebuilt libs (selected by `ros_bridge`), so init is much faster and no longer runs a Docker build for the workspace. It reports the bridge distro in use instead of a build result
19
+ - `pow init` no longer creates the `scripts/`, `.assets/`, and `standalone/` project folders (only `exts/`, `.modules/`, `usda/`). A manually created `scripts/` folder is still mounted into the ROS container when present
20
+ - The `pow_simros_jazzy` image now resolves workspace dependencies with `rosdep install` at build time instead of building the workspace, and source-only packages (`topic_based_ros2_control`) are staged in `/opt/pow/extra_src` and copied into `/jazzy_ws/src` at container start, so the runtime volume mount no longer shadows them
21
+
22
+ ### Fixed
23
+
24
+ - **`rosdep install` failed inside the Jazzy container** — three causes, all fixed (requires rebuilding the image): the Ubuntu 24.04 base image's stock `ubuntu` user took UID 1000 and collided with the mapped host user, so `HOME` pointed at `/home/ubuntu` while the host `~/.ros` was mounted at `/home/hostuser/.ros`; the per-user rosdep cache was empty because `rosdep update` had run as root at build time, giving "your rosdep installation has not been initialized"; and `ros-jazzy-ros-testing` was missing from the image
25
+ - **`cpu_performance_mode` asked for the sudo password on every launch** — `pow run` / `pow python` now read the current CPU governor from `/sys/devices/system/cpu/cpu*/cpufreq/scaling_governor` first and run no `sudo` at all when it is already `performance`, so the password is normally needed once instead of every launch. When sudo credentials are still cached the governor is set without the "requires sudo" notice, and hosts without `cpupower` or without cpufreq support now report and skip rather than prompting for a password to no purpose. The interactive prompt itself is unchanged when the governor really has to be switched
26
+ - **`ros2` tab completion inside the `pow_simros` container** — the entrypoint sourced ROS before exec'ing bash, which carried environment variables but not bash completion functions. Interactive shells now re-source `/opt/ros/jazzy/setup.bash` and the workspace overlay via `/etc/bash.bashrc`, so `ros2` / `colcon` autocomplete works without manually sourcing `setup.bash` (requires rebuilding the image)
27
+
28
+ ---
29
+
5
30
  ## [0.2.0-rc.1] - 2026-06-14
6
31
 
7
32
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pow-cli
3
- Version: 0.2.0rc1
3
+ Version: 0.2.0rc2
4
4
  Summary: Command line interface for Isaac Powerpack
5
5
  Author: Titiwat Munintravong, Isaac Powerpack Contributors
6
6
  License-Expression: Apache-2.0
@@ -307,7 +307,6 @@ def list_cmd():
307
307
  console.print()
308
308
  try:
309
309
  from ..core.asset_manager import REGISTRIES
310
- from itertools import groupby
311
310
 
312
311
  target_groups = set()
313
312
  for r in REGISTRIES:
@@ -6,11 +6,10 @@ from pathlib import Path
6
6
  import click
7
7
  from rich.panel import Panel
8
8
  from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
9
- from rich.prompt import Confirm, Prompt
10
- from rich.text import Text
9
+ from rich.prompt import Confirm
11
10
 
11
+ from ..common.prompt import ask_path
12
12
  from ..common.utils import console
13
- from ..core.models.pow_config import PowConfig
14
13
  from ..core.initializer import Initializer
15
14
  from ..core.ros_manager import RosManager
16
15
 
@@ -63,7 +62,7 @@ def _step1_check_config(global_dir_name: str) -> bool:
63
62
  """Print config header and verify pyproject.toml exists. Return False to abort."""
64
63
  if not Path("pyproject.toml").exists():
65
64
  console.print(
66
- "\n[bold red][1/8] ❌ Error:[/bold red] pyproject.toml not found. "
65
+ "\n[bold red][1/10] ❌ Error:[/bold red] pyproject.toml not found. "
67
66
  "Please run this command in a valid project directory."
68
67
  )
69
68
  return False
@@ -260,7 +259,7 @@ def _step6_ros_integration(
260
259
  ws_path = forced_ws
261
260
  console.print(f" Using existing workspace path from pow.toml: [bold green]{ws_path}[/bold green]")
262
261
  else:
263
- ws_path = Prompt.ask(
262
+ ws_path = ask_path(
264
263
  " Path to clone IsaacSim-ros_workspaces",
265
264
  default=default_ws,
266
265
  )
@@ -277,28 +276,19 @@ def _step6_ros_integration(
277
276
  console.print(f" [dim]Workspace path:[/dim] {display_path}")
278
277
 
279
278
  ros_cloned = False
280
- ros_already_built = False
281
279
 
282
280
  def ros_status_callback(state):
283
- nonlocal ros_cloned, ros_already_built
281
+ nonlocal ros_cloned
284
282
  if state == "cloning":
285
283
  ros_cloned = True
286
284
  status.update("[bold green]Cloning Isaac Sim ROS workspace...")
287
285
  elif state == "existed":
288
- status.update("[bold yellow]Isaac Sim ROS workspace already exists. Checking build...")
289
- elif state == "built":
290
- ros_already_built = True
291
- status.update("[bold yellow]Docker build already complete.")
292
- elif state == "building":
293
- status.update("[bold green]Docker build: Isaac Sim ROS workspace...")
294
- elif state.startswith("building:"):
295
- line = state[len("building:"):]
296
- status.update(f"[bold green]Docker build:[/bold green] [dim]{line[:80]}[/dim]")
286
+ status.update("[bold yellow]Isaac Sim ROS workspace already exists.")
297
287
 
298
288
  # Resolve the tilde path for actual filesystem operations
299
289
  resolved_ws = Path(ws_path).expanduser()
300
290
 
301
- ros_build_failed = False
291
+ ros_setup_failed = False
302
292
 
303
293
  with console.status("Preparing ROS workspace...") as status:
304
294
  try:
@@ -307,11 +297,11 @@ def _step6_ros_integration(
307
297
  ws_path=resolved_ws,
308
298
  )
309
299
  except Exception as e:
310
- ros_build_failed = True
300
+ ros_setup_failed = True
311
301
  console.print(f" [bold red]❌ ROS Setup Error:[/bold red] {e}")
312
302
 
313
- if ros_build_failed:
314
- console.print(" [yellow]⊖[/yellow] Skipping remaining steps due to ROS build error.")
303
+ if ros_setup_failed:
304
+ console.print(" [yellow]⊖[/yellow] Skipping remaining steps due to ROS setup error.")
315
305
  raise SystemExit(1)
316
306
 
317
307
  if ros_cloned:
@@ -319,11 +309,12 @@ def _step6_ros_integration(
319
309
  else:
320
310
  console.print(f" [yellow]✔[/yellow] IsaacSim-ros_workspaces already available in [dim]{display_path}[/dim]")
321
311
 
322
- distro_label = f"ROS [bold]{ros_res['ros_distro']}[/bold] (Ubuntu {ros_res['ubuntu_version']})"
323
- if ros_already_built:
324
- console.print(f" [yellow]✔[/yellow] Docker build already complete for {distro_label}.")
325
- else:
326
- console.print(f" [green]✔[/green] Docker build complete for {distro_label}.")
312
+ bridge_distro = ros_mgr.config.ros_bridge
313
+ console.print(
314
+ f" [green]✔[/green] ROS bridge: [bold]{bridge_distro}[/bold] "
315
+ f"(ros_bridge in pow.toml, host Ubuntu {ros_res['ubuntu_version']}) "
316
+ f"via Isaac Sim internal libs."
317
+ )
327
318
 
328
319
  # Build pow_simros Docker image
329
320
  simros_already_built = False
@@ -341,7 +332,7 @@ def _step6_ros_integration(
341
332
 
342
333
  with console.status("Building pow_simros image...") as simros_status:
343
334
  try:
344
- simros_res = ros_mgr.build_simros_image(
335
+ ros_mgr.build_simros_image(
345
336
  status_callback=simros_status_callback,
346
337
  ws_path=resolved_ws,
347
338
  )
@@ -359,7 +350,7 @@ def _step6_ros_integration(
359
350
  # Build custom ROS image layered on top of pow_simros_<distro>
360
351
  custom_dockerfile = ros_mgr.config.ros_dockerfile
361
352
  if custom_dockerfile:
362
- custom_image = ros_mgr.config.ros_container_name
353
+ custom_image = ros_mgr.config.ros_docker_image
363
354
 
364
355
  def custom_status_callback(state):
365
356
  if state == "custom_building":
@@ -387,7 +378,7 @@ def _step6_ros_integration(
387
378
  def _step7_project_structure(initializer: Initializer):
388
379
  """Create local project folders and .gitignore."""
389
380
  console.print("[bold blue][7/10] 🏗️ Project Structure:[/bold blue] Creating local folders...")
390
- local_folders = ["exts", "scripts", ".modules", ".assets", "standalone", "usda"]
381
+ local_folders = ["exts", ".modules", "usda"]
391
382
 
392
383
  with Progress(
393
384
  SpinnerColumn(),
@@ -5,8 +5,6 @@ from pathlib import Path
5
5
  import click
6
6
  from rich.markup import escape
7
7
  from rich.panel import Panel
8
- from rich.table import Table
9
- from rich.text import Text
10
8
 
11
9
  from ..common.utils import console
12
10
  from ..core.linter import AliasConfig, lint_file, fix_file, scan_directory
@@ -7,7 +7,8 @@ from .init import init_cmd
7
7
  from .check import check_cmd
8
8
  from .lint import lint_group
9
9
  from .run import run_cmd
10
- from .ros import ros_cmd
10
+ from .sim import sim_cmd
11
+ from .ros import ros_group
11
12
  from .python import python_cmd
12
13
  from .asset import asset_group
13
14
 
@@ -32,6 +33,7 @@ pow_group.add_command(init_cmd)
32
33
  pow_group.add_command(check_cmd)
33
34
  pow_group.add_command(lint_group)
34
35
  pow_group.add_command(run_cmd)
35
- pow_group.add_command(ros_cmd)
36
+ pow_group.add_command(sim_cmd)
37
+ pow_group.add_command(ros_group)
36
38
  pow_group.add_command(python_cmd)
37
39
  pow_group.add_command(asset_group)
@@ -0,0 +1,155 @@
1
+ """Ros command implementation."""
2
+
3
+ import click
4
+ from rich.panel import Panel
5
+ from ..common.utils import console
6
+ from ..core.ros_manager import RosManager
7
+
8
+
9
+ def _launch_container(extra_args, verbose: bool):
10
+ """Launch or attach to the ROS container, rendering errors as panels."""
11
+ try:
12
+ RosManager.run_simros_container(extra_args=extra_args, verbose=verbose)
13
+ except click.ClickException:
14
+ raise
15
+ except Exception as e:
16
+ console.print(
17
+ Panel(
18
+ f"[bold red]✘[/bold red] {e}",
19
+ title="[bold red]ROS Error[/bold red]",
20
+ border_style="red",
21
+ )
22
+ )
23
+ raise SystemExit(1)
24
+
25
+
26
+ class RosGroup(click.Group):
27
+ """Group that falls through to `launch` for unrecognized arguments."""
28
+
29
+ def resolve_command(self, ctx, args):
30
+ if args and self.get_command(ctx, args[0]) is not None:
31
+ return super().resolve_command(ctx, args)
32
+ launch = self.get_command(ctx, "launch")
33
+ return launch.name, launch, args
34
+
35
+
36
+ @click.group(
37
+ name="ros",
38
+ cls=RosGroup,
39
+ invoke_without_command=True,
40
+ context_settings={"ignore_unknown_options": True},
41
+ )
42
+ @click.pass_context
43
+ def ros_group(ctx: click.Context):
44
+ """Launch or manage the ROS docker container.
45
+
46
+ Without a subcommand, launches an interactive session in the container
47
+ running the image from `ros_docker_image` in pow.toml (or attaches to it
48
+ if it is already running). Unrecognized arguments are forwarded as the
49
+ container command (default: /bin/bash), e.g. `pow ros ros2 topic list`.
50
+
51
+ \b
52
+ Subcommands:
53
+ launch Explicitly launch the container (same as bare `pow ros`).
54
+ build Build the custom image from `ros_dockerfile`.
55
+
56
+ \b
57
+ Requires:
58
+ - ROS integration enabled in pow.toml (enable_ros = true).
59
+ - The pow_simros Docker image built via `pow init`.
60
+ """
61
+ if ctx.invoked_subcommand is None:
62
+ _launch_container(None, False)
63
+
64
+
65
+ @ros_group.command(
66
+ name="launch",
67
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
68
+ )
69
+ @click.option("--verbose", "-v", is_flag=True, default=False, help="Show detailed feedback during container launch.")
70
+ @click.pass_context
71
+ def launch_cmd(ctx: click.Context, verbose: bool):
72
+ """Launch the ROS container, forwarding extra args as the container command."""
73
+ _launch_container(ctx.args or None, verbose)
74
+
75
+
76
+ @ros_group.command(name="build")
77
+ @click.option("--no-cache", is_flag=True, default=False, help="Build without using the Docker layer cache.")
78
+ def build_cmd(no_cache: bool):
79
+ """Build the custom ROS image from `ros_dockerfile` in pow.toml."""
80
+ ros_mgr = RosManager()
81
+ config = ros_mgr.config
82
+
83
+ if config.project_root is None:
84
+ raise click.ClickException("Not initialized. Run `pow init` first.")
85
+
86
+ if not config.ros_dockerfile:
87
+ console.print(
88
+ "[yellow]No `ros_dockerfile` set in pow.toml; nothing to build.[/yellow]\n"
89
+ f"The bundled [bold]pow_simros_{config.ros_distro}[/bold] image is built by `pow init`."
90
+ )
91
+ return
92
+
93
+ base_image = f"pow_simros_{config.ros_distro}"
94
+ if not RosManager.image_exists(base_image):
95
+ distro_ws = config.ros_ws_path / f"{config.ros_distro}_ws"
96
+ if not distro_ws.exists():
97
+ raise click.ClickException(
98
+ f"Base image '{base_image}' not found and the ROS workspace "
99
+ f"'{distro_ws}' does not exist.\n"
100
+ "Run `pow init` first to set up the ROS workspace."
101
+ )
102
+
103
+ console.print(f"[yellow]Base image '{base_image}' not found — building it first.[/yellow]")
104
+
105
+ def simros_status_callback(state):
106
+ if state == "simros_building":
107
+ base_status.update(f"[bold green]Building base image '{base_image}'...")
108
+ elif state.startswith("simros_building:"):
109
+ line = state[len("simros_building:"):]
110
+ base_status.update(f"[bold green]{base_image} build:[/bold green] [dim]{line[:80]}[/dim]")
111
+
112
+ with console.status(f"Building base image '{base_image}'...") as base_status:
113
+ try:
114
+ ros_mgr.build_simros_image(status_callback=simros_status_callback)
115
+ except Exception as e:
116
+ console.print(
117
+ Panel(
118
+ f"[bold red]✘[/bold red] {e}",
119
+ title="[bold red]ROS Build Error[/bold red]",
120
+ border_style="red",
121
+ )
122
+ )
123
+ raise SystemExit(1)
124
+
125
+ console.print(f"[green]✔[/green] Base Docker image [bold]{base_image}[/bold] built successfully.")
126
+
127
+ custom_image = config.ros_docker_image
128
+
129
+ def custom_status_callback(state):
130
+ if state == "custom_building":
131
+ custom_status.update(f"[bold green]Building custom image '{custom_image}'...")
132
+ elif state.startswith("custom_building:"):
133
+ line = state[len("custom_building:"):]
134
+ custom_status.update(f"[bold green]custom build:[/bold green] [dim]{line[:80]}[/dim]")
135
+
136
+ with console.status(f"Building custom image '{custom_image}'...") as custom_status:
137
+ try:
138
+ ros_mgr.build_custom_ros_image(
139
+ status_callback=custom_status_callback,
140
+ no_cache=no_cache,
141
+ )
142
+ except Exception as e:
143
+ console.print(
144
+ Panel(
145
+ f"[bold red]✘[/bold red] {e}",
146
+ title="[bold red]ROS Build Error[/bold red]",
147
+ border_style="red",
148
+ )
149
+ )
150
+ raise SystemExit(1)
151
+
152
+ console.print(
153
+ f"[green]✔[/green] Custom Docker image [bold]{custom_image}[/bold] "
154
+ f"built from [dim]{config.ros_dockerfile}[/dim]."
155
+ )
@@ -1,8 +1,6 @@
1
1
  """Run command implementation."""
2
2
 
3
3
  import click
4
- from rich.panel import Panel
5
- from ..common.utils import console
6
4
  from ..core.runner import Runner
7
5
 
8
6
  @click.command(
@@ -0,0 +1,67 @@
1
+ """Sim command implementation."""
2
+
3
+ import click
4
+ from rich.panel import Panel
5
+ from ..common.utils import console
6
+ from ..core.models.pow_config import PowConfig
7
+ from ..core.runner import Runner
8
+
9
+
10
+ @click.command(
11
+ name="sim",
12
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
13
+ )
14
+ @click.option(
15
+ "-v",
16
+ "--version",
17
+ "sim_version",
18
+ default=PowConfig.ISAACSIM_VERSION,
19
+ show_default=True,
20
+ help="Isaac Sim version to run.",
21
+ )
22
+ @click.option(
23
+ "--ros",
24
+ "ros_bridge",
25
+ type=click.Choice(PowConfig.SUPPORTED_ROS_BRIDGES),
26
+ default=PowConfig.ROS_BRIDGE,
27
+ show_default=True,
28
+ help="ROS 2 bridge distro to load into the environment.",
29
+ )
30
+ @click.option(
31
+ "--no-ros",
32
+ is_flag=True,
33
+ default=False,
34
+ help="Launch without the ROS 2 bridge environment.",
35
+ )
36
+ @click.pass_context
37
+ def sim_cmd(ctx: click.Context, sim_version: str, ros_bridge: str, no_ros: bool):
38
+ """Run Isaac Sim from any directory, ignoring pow.toml.
39
+
40
+ \b
41
+ Unlike `pow run` this needs no project: no pyproject.toml is required and
42
+ pow.toml is never read. Every unrecognized argument is forwarded verbatim
43
+ to .pow/isaacsim/<version>/isaac-sim.sh.
44
+
45
+ \b
46
+ Examples:
47
+ pow sim
48
+ pow sim --no-ros
49
+ pow sim -- --no-window --/renderer/enabled=gpu
50
+ """
51
+ try:
52
+ Runner.run_sim(
53
+ version=sim_version,
54
+ ros_bridge=None if no_ros else ros_bridge,
55
+ extra_args=ctx.args,
56
+ )
57
+ except click.ClickException:
58
+ raise
59
+ except Exception as e:
60
+ console.print(
61
+ Panel(
62
+ f"[bold red]✘[/bold red] {e}",
63
+ title="[bold red]Sim Error[/bold red]",
64
+ border_style="red",
65
+ )
66
+ )
67
+ raise SystemExit(1)
@@ -0,0 +1,136 @@
1
+ """Interactive prompts with shell-style TAB path completion."""
2
+
3
+ import glob
4
+ import os
5
+ import re
6
+ from contextlib import contextmanager
7
+ from pathlib import Path
8
+
9
+ from rich.prompt import Prompt
10
+
11
+ from .utils import console
12
+
13
+ # ANSI SGR sequences must be hidden from readline's column arithmetic, otherwise
14
+ # the cursor lands in the wrong place after a completion redisplay.
15
+ _ANSI_RE = re.compile(r"(\x1b\[[0-9;]*m)")
16
+
17
+
18
+ def complete_path(text: str, dirs_only: bool = False) -> list[str]:
19
+ """Return the filesystem completions for *text*, shell style.
20
+
21
+ Directories come back with a trailing separator, and a leading ``~`` is
22
+ preserved so the completed value stays tilde-relative.
23
+ """
24
+ expanded = os.path.expanduser(text)
25
+ matches = glob.glob(expanded + "*")
26
+
27
+ if dirs_only:
28
+ matches = [m for m in matches if os.path.isdir(m)]
29
+
30
+ matches = [m + os.sep if os.path.isdir(m) else m for m in matches]
31
+
32
+ if text.startswith("~"):
33
+ home = str(Path.home())
34
+ matches = [
35
+ "~" + m[len(home):] if m.startswith(home) else m
36
+ for m in matches
37
+ ]
38
+
39
+ return sorted(matches)
40
+
41
+
42
+ @contextmanager
43
+ def _path_completion(dirs_only: bool = False):
44
+ """Install a path completer on readline for the duration of one prompt.
45
+
46
+ ``readline`` is imported lazily: importing it changes ``input()`` behaviour
47
+ process-wide, which should not happen just because some other command was
48
+ run. Yields ``True`` when completion is active, ``False`` when readline is
49
+ unavailable (no completion, prompt still works).
50
+ """
51
+ try:
52
+ import readline
53
+ except ImportError:
54
+ yield False
55
+ return
56
+
57
+ matches: list[str] = []
58
+
59
+ def completer(text: str, state: int):
60
+ nonlocal matches
61
+ if state == 0:
62
+ # Delims are "\n" (see below), so `text` is the whole line and each
63
+ # match replaces it entirely. rstrip() absorbs any stray trailing
64
+ # space.
65
+ matches = complete_path(text.rstrip(), dirs_only=dirs_only)
66
+ if len(matches) == 1 and matches[0].endswith(os.sep):
67
+ # readline appends a space after a *unique* match and Python
68
+ # exposes no way to turn that off. For a directory that would
69
+ # strand the cursor behind a space; duplicating the match makes
70
+ # it non-unique, so the inserted text is exactly the path and
71
+ # the next segment can be typed straight away, shell style.
72
+ # Files keep the space, which is what a shell does too.
73
+ matches = matches * 2
74
+ return matches[state] if state < len(matches) else None
75
+
76
+ prev_completer = readline.get_completer()
77
+ prev_delims = readline.get_completer_delims()
78
+
79
+ # Breaking words on "\n" only (never present in a line) makes the whole line
80
+ # the completion word. The default delims include "/", "~" and "-", which
81
+ # would hand the completer useless fragments; this also lets paths with
82
+ # spaces complete correctly.
83
+ readline.set_completer_delims("\n")
84
+ readline.set_completer(completer)
85
+
86
+ # uv-managed CPython links libedit, which needs its own binding syntax.
87
+ if "libedit" in (readline.__doc__ or ""):
88
+ readline.parse_and_bind("bind ^I rl_complete")
89
+ else:
90
+ readline.parse_and_bind("tab: complete")
91
+
92
+ try:
93
+ yield True
94
+ finally:
95
+ readline.set_completer(prev_completer)
96
+ readline.set_completer_delims(prev_delims)
97
+
98
+
99
+ class PathPrompt(Prompt):
100
+ """A :class:`rich.prompt.Prompt` whose input line completes paths on TAB."""
101
+
102
+ dirs_only: bool = False
103
+
104
+ @classmethod
105
+ def get_input(cls, console, prompt, password: bool, stream=None) -> str:
106
+ if password or stream is not None:
107
+ return super().get_input(console, prompt, password, stream=stream)
108
+
109
+ # Hand the rendered prompt to input() rather than printing it first:
110
+ # readline needs to know the prompt to redraw the line after listing
111
+ # matches, otherwise the prompt vanishes on the first ambiguous TAB.
112
+ with console.capture() as capture:
113
+ console.print(prompt, end="")
114
+ rendered = _ANSI_RE.sub("\x01\\1\x02", capture.get())
115
+
116
+ with _path_completion(dirs_only=cls.dirs_only):
117
+ return input(rendered)
118
+
119
+
120
+ class _DirPrompt(PathPrompt):
121
+ dirs_only = True
122
+
123
+
124
+ def ask_path(message: str, *, default: str, dirs_only: bool = True) -> str:
125
+ """Ask for a filesystem path, completing it with TAB like a shell does.
126
+
127
+ Trailing whitespace and a trailing separator are stripped, since TAB
128
+ completion makes both common and the value is stored as-is in pow.toml.
129
+ """
130
+ prompt_cls = _DirPrompt if dirs_only else PathPrompt
131
+ value = prompt_cls.ask(message, default=default, console=console).strip()
132
+
133
+ if len(value) > 1:
134
+ value = value.rstrip(os.sep)
135
+
136
+ return value