simulo 0.2.5__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.
Files changed (81) hide show
  1. simulo-0.6.0/LICENSE +29 -0
  2. simulo-0.6.0/PKG-INFO +81 -0
  3. simulo-0.6.0/README.md +38 -0
  4. simulo-0.6.0/cli/src/simulo_cli/__init__.py +1 -0
  5. simulo-0.6.0/cli/src/simulo_cli/__main__.py +6 -0
  6. simulo-0.6.0/cli/src/simulo_cli/api/__init__.py +1 -0
  7. simulo-0.6.0/cli/src/simulo_cli/api/client.py +98 -0
  8. simulo-0.6.0/cli/src/simulo_cli/auth/__init__.py +1 -0
  9. simulo-0.6.0/cli/src/simulo_cli/auth/credentials.py +57 -0
  10. simulo-0.6.0/cli/src/simulo_cli/cli.py +33 -0
  11. simulo-0.6.0/cli/src/simulo_cli/commands/__init__.py +1 -0
  12. simulo-0.6.0/cli/src/simulo_cli/commands/auth.py +272 -0
  13. simulo-0.6.0/cli/src/simulo_cli/commands/org.py +29 -0
  14. simulo-0.6.0/cli/src/simulo_cli/config.py +138 -0
  15. simulo-0.6.0/pyproject.toml +185 -0
  16. simulo-0.6.0/sdk/src/simulo/__init__.py +162 -0
  17. simulo-0.6.0/sdk/src/simulo/backend/__init__.py +1 -0
  18. simulo-0.6.0/sdk/src/simulo/backend/isaaclab/__init__.py +5 -0
  19. simulo-0.6.0/sdk/src/simulo/backend/isaaclab/asset_registry.py +365 -0
  20. simulo-0.6.0/sdk/src/simulo/backend/isaaclab/backend.py +227 -0
  21. simulo-0.6.0/sdk/src/simulo/backend/isaaclab/scene_builder.py +2237 -0
  22. simulo-0.6.0/sdk/src/simulo/backend/isaaclab/stream_adapter.py +182 -0
  23. simulo-0.6.0/sdk/src/simulo/core/__init__.py +88 -0
  24. simulo-0.6.0/sdk/src/simulo/core/actuator.py +314 -0
  25. simulo-0.6.0/sdk/src/simulo/core/asset.py +63 -0
  26. simulo-0.6.0/sdk/src/simulo/core/controller.py +1012 -0
  27. simulo-0.6.0/sdk/src/simulo/core/entity.py +180 -0
  28. simulo-0.6.0/sdk/src/simulo/core/env.py +553 -0
  29. simulo-0.6.0/sdk/src/simulo/core/light.py +84 -0
  30. simulo-0.6.0/sdk/src/simulo/core/materials.py +61 -0
  31. simulo-0.6.0/sdk/src/simulo/core/math_utils.py +74 -0
  32. simulo-0.6.0/sdk/src/simulo/core/observations.py +481 -0
  33. simulo-0.6.0/sdk/src/simulo/core/physics.py +154 -0
  34. simulo-0.6.0/sdk/src/simulo/core/player.py +604 -0
  35. simulo-0.6.0/sdk/src/simulo/core/policy.py +245 -0
  36. simulo-0.6.0/sdk/src/simulo/core/pose.py +99 -0
  37. simulo-0.6.0/sdk/src/simulo/core/primitives.py +132 -0
  38. simulo-0.6.0/sdk/src/simulo/core/rewards.py +798 -0
  39. simulo-0.6.0/sdk/src/simulo/core/robot.py +1041 -0
  40. simulo-0.6.0/sdk/src/simulo/core/scene.py +462 -0
  41. simulo-0.6.0/sdk/src/simulo/core/sensor.py +923 -0
  42. simulo-0.6.0/sdk/src/simulo/core/task.py +215 -0
  43. simulo-0.6.0/sdk/src/simulo/core/terminations.py +326 -0
  44. simulo-0.6.0/sdk/src/simulo/core/terrain.py +380 -0
  45. simulo-0.6.0/sdk/src/simulo/core/trainer.py +437 -0
  46. simulo-0.6.0/sdk/src/simulo/core/visual.py +302 -0
  47. simulo-0.6.0/sdk/src/simulo/core/world.py +138 -0
  48. simulo-0.6.0/sdk/src/simulo/scenario.py +196 -0
  49. simulo-0.6.0/sdk/src/simulo/streaming/__init__.py +31 -0
  50. simulo-0.6.0/sdk/src/simulo/streaming/adapter.py +209 -0
  51. simulo-0.6.0/sdk/src/simulo/streaming/config.py +90 -0
  52. simulo-0.6.0/sdk/src/simulo/streaming/types.py +103 -0
  53. {simulo-0.2.5 → simulo-0.6.0}/setup.cfg +4 -4
  54. simulo-0.6.0/simulo.egg-info/PKG-INFO +81 -0
  55. simulo-0.6.0/simulo.egg-info/SOURCES.txt +59 -0
  56. simulo-0.6.0/simulo.egg-info/entry_points.txt +2 -0
  57. simulo-0.6.0/simulo.egg-info/requires.txt +23 -0
  58. simulo-0.6.0/simulo.egg-info/top_level.txt +2 -0
  59. simulo-0.2.5/LICENSE +0 -1
  60. simulo-0.2.5/MANIFEST.in +0 -1
  61. simulo-0.2.5/PKG-INFO +0 -27
  62. simulo-0.2.5/README.rst +0 -6
  63. simulo-0.2.5/setup.py +0 -47
  64. simulo-0.2.5/simulo/auth/authenticator.py +0 -56
  65. simulo-0.2.5/simulo/cli/__init__.py +0 -0
  66. simulo-0.2.5/simulo/cli/runner.py +0 -80
  67. simulo-0.2.5/simulo/config.py +0 -13
  68. simulo-0.2.5/simulo/simulomath.py +0 -27
  69. simulo-0.2.5/simulo/upload/__init__.py +0 -0
  70. simulo-0.2.5/simulo/upload/data_upload.py +0 -259
  71. simulo-0.2.5/simulo/utils/__init__.py +0 -0
  72. simulo-0.2.5/simulo/utils/json_transformations.py +0 -27
  73. simulo-0.2.5/simulo/utils/unique_id.py +0 -4
  74. simulo-0.2.5/simulo.egg-info/PKG-INFO +0 -27
  75. simulo-0.2.5/simulo.egg-info/SOURCES.txt +0 -22
  76. simulo-0.2.5/simulo.egg-info/entry_points.txt +0 -3
  77. simulo-0.2.5/simulo.egg-info/requires.txt +0 -5
  78. simulo-0.2.5/simulo.egg-info/top_level.txt +0 -1
  79. /simulo-0.2.5/simulo/__init__.py → /simulo-0.6.0/cli/src/simulo_cli/py.typed +0 -0
  80. /simulo-0.2.5/simulo/auth/__init__.py → /simulo-0.6.0/sdk/src/simulo/py.typed +0 -0
  81. {simulo-0.2.5 → simulo-0.6.0}/simulo.egg-info/dependency_links.txt +0 -0
simulo-0.6.0/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Simulo Team
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
simulo-0.6.0/PKG-INFO ADDED
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: simulo
3
+ Version: 0.6.0
4
+ Summary: Simulo — robotics simulation SDK and CLI (Linux + CUDA GPU required)
5
+ Author-email: Simulo Team <team@simulo.ai>
6
+ License: BSD-3-Clause
7
+ Project-URL: Homepage, https://simulo.ai
8
+ Project-URL: Documentation, https://docs.simulo.ai
9
+ Project-URL: Source, https://github.com/simulo-ai/simulo-platform
10
+ Keywords: robotics,simulation,reinforcement-learning,imitation-learning,isaacsim
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: BSD License
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Environment :: GPU :: NVIDIA CUDA
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: click>=8.1
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: pydantic-settings>=2.3
24
+ Requires-Dist: numpy>=1.20
25
+ Requires-Dist: gymnasium>=0.29
26
+ Requires-Dist: torch>=2.0
27
+ Requires-Dist: isaaclab[all,isaacsim]==2.3.2.post1
28
+ Requires-Dist: skrl<2,>=1.4.3
29
+ Provides-Extra: dev
30
+ Requires-Dist: black>=24.0; extra == "dev"
31
+ Requires-Dist: isort>=5.13; extra == "dev"
32
+ Requires-Dist: ruff>=0.5; extra == "dev"
33
+ Requires-Dist: pytest>=8.2; extra == "dev"
34
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
35
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
36
+ Requires-Dist: pip-audit>=2.7; extra == "dev"
37
+ Requires-Dist: mypy>=1.10; extra == "dev"
38
+ Provides-Extra: release
39
+ Requires-Dist: commitizen>=3.27; extra == "release"
40
+ Requires-Dist: build>=1.2; extra == "release"
41
+ Requires-Dist: twine>=5.1; extra == "release"
42
+ Dynamic: license-file
43
+
44
+ # simulo
45
+
46
+ The unified `simulo` PyPI package — robotics simulation SDK and CLI for
47
+ NVIDIA Isaac Sim / IsaacLab.
48
+
49
+ **Linux x86_64 with an NVIDIA CUDA GPU required.** Other platforms are
50
+ not supported.
51
+
52
+ ## Install
53
+
54
+ See [INSTALL.md](INSTALL.md) for the canonical two-step install
55
+ (`pip install simulo --extra-index-url https://pypi.nvidia.com` plus the
56
+ NVIDIA-pinned `torch` wheel).
57
+
58
+ ## Status
59
+
60
+ This is a **Wave A skeleton** of the unified-package migration described
61
+ in `context/design/unified-simulo-pypi-package.md` and
62
+ `context/implementation-plans/unified-simulo-pypi-package-wave-plan.md`.
63
+ The SDK and CLI source trees are added in Waves B and C; the conda env
64
+ is unified in Wave E; the first release (`0.5.0`) ships from Wave G.
65
+
66
+ ## Layout
67
+
68
+ ```
69
+ simulo-devkit/packages/simulo/
70
+ ├── pyproject.toml # umbrella package (name = "simulo")
71
+ ├── README.md # you are here
72
+ ├── INSTALL.md # end-user install instructions
73
+ ├── LICENSE # BSD 3-Clause
74
+ ├── environment.yml # conda env: env_simulo-devkit (py3.11)
75
+ ├── sdk/
76
+ │ ├── src/simulo/ # SDK module (populated in Wave B)
77
+ │ └── tests/
78
+ └── cli/
79
+ ├── src/simulo_cli/ # CLI module (populated in Wave C)
80
+ └── tests/
81
+ ```
simulo-0.6.0/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # simulo
2
+
3
+ The unified `simulo` PyPI package — robotics simulation SDK and CLI for
4
+ NVIDIA Isaac Sim / IsaacLab.
5
+
6
+ **Linux x86_64 with an NVIDIA CUDA GPU required.** Other platforms are
7
+ not supported.
8
+
9
+ ## Install
10
+
11
+ See [INSTALL.md](INSTALL.md) for the canonical two-step install
12
+ (`pip install simulo --extra-index-url https://pypi.nvidia.com` plus the
13
+ NVIDIA-pinned `torch` wheel).
14
+
15
+ ## Status
16
+
17
+ This is a **Wave A skeleton** of the unified-package migration described
18
+ in `context/design/unified-simulo-pypi-package.md` and
19
+ `context/implementation-plans/unified-simulo-pypi-package-wave-plan.md`.
20
+ The SDK and CLI source trees are added in Waves B and C; the conda env
21
+ is unified in Wave E; the first release (`0.5.0`) ships from Wave G.
22
+
23
+ ## Layout
24
+
25
+ ```
26
+ simulo-devkit/packages/simulo/
27
+ ├── pyproject.toml # umbrella package (name = "simulo")
28
+ ├── README.md # you are here
29
+ ├── INSTALL.md # end-user install instructions
30
+ ├── LICENSE # BSD 3-Clause
31
+ ├── environment.yml # conda env: env_simulo-devkit (py3.11)
32
+ ├── sdk/
33
+ │ ├── src/simulo/ # SDK module (populated in Wave B)
34
+ │ └── tests/
35
+ └── cli/
36
+ ├── src/simulo_cli/ # CLI module (populated in Wave C)
37
+ └── tests/
38
+ ```
@@ -0,0 +1 @@
1
+ # simulo-cli — Simulo command-line interface
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m simulo_cli`."""
2
+
3
+ from simulo_cli.cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -0,0 +1 @@
1
+ # simulo_cli.api — HTTP client for simulo-control-plane
@@ -0,0 +1,98 @@
1
+ """SimuloClient — HTTP client for the simulo-control-plane REST API.
2
+
3
+ Wave 4: full implementation with bearer-token injection and silent auto-refresh.
4
+
5
+ The client:
6
+ - reads credentials from ~/.simulo/credentials
7
+ - checks token expiry before every authenticated call
8
+ - silently refreshes via POST /auth/token/refresh when expired
9
+ - injects Authorization: Bearer <access_token> on every authenticated call
10
+ - provides post_unauth() for endpoints that require no auth (e.g. /auth/cli/callback)
11
+
12
+ See .claude/skills/cli-auth/SKILL.md §API Client for the specification.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from datetime import datetime, timezone
18
+
19
+ import httpx
20
+
21
+ from simulo_cli.auth.credentials import load_credentials, save_credentials
22
+ from simulo_cli.config import get_settings
23
+
24
+
25
+ class SimuloClient:
26
+ """Thin HTTP client wrapping simulo-control-plane.
27
+
28
+ All public methods accept the same ``**kwargs`` as the underlying
29
+ ``httpx`` call (e.g. ``json=``, ``params=``, ``timeout=``).
30
+ """
31
+
32
+ def __init__(self) -> None:
33
+ self.settings = get_settings()
34
+ self.base_url = self.settings.api_base_url
35
+
36
+ # ------------------------------------------------------------------
37
+ # Token management
38
+ # ------------------------------------------------------------------
39
+
40
+ def _get_token(self) -> str:
41
+ """Return a valid access token, refreshing silently if expired."""
42
+ creds = load_credentials()
43
+ expires_at = datetime.fromisoformat(creds["expires_at"])
44
+ if datetime.now(timezone.utc) >= expires_at:
45
+ creds = self._refresh(creds)
46
+ return creds["access_token"]
47
+
48
+ def _refresh(self, creds: dict) -> dict:
49
+ """POST /auth/token/refresh and update credentials file.
50
+
51
+ Preserves refresh_token and active_organization_id from existing creds.
52
+ Raises SystemExit on failure so the caller sees a clean message.
53
+ """
54
+ resp = httpx.post(
55
+ f"{self.base_url}/auth/token/refresh",
56
+ json={"refresh_token": creds["refresh_token"]},
57
+ timeout=10,
58
+ )
59
+ if resp.status_code != 200:
60
+ err: dict = {}
61
+ try:
62
+ err = (resp.json() or {}).get("error") or {}
63
+ except (ValueError, AttributeError):
64
+ pass
65
+ if err.get("code") == "cognito_unavailable":
66
+ raise SystemExit("Auth provider temporarily unavailable. Try again in a moment.")
67
+ raise SystemExit("Session expired. Run: simulo login")
68
+
69
+ payload = resp.json()
70
+ new_creds = {
71
+ **creds,
72
+ "access_token": payload["access_token"],
73
+ "expires_at": payload["expires_at"],
74
+ }
75
+ save_credentials(new_creds)
76
+ return new_creds
77
+
78
+ def _headers(self) -> dict[str, str]:
79
+ return {"Authorization": f"Bearer {self._get_token()}"}
80
+
81
+ # ------------------------------------------------------------------
82
+ # Public HTTP helpers
83
+ # ------------------------------------------------------------------
84
+
85
+ def get(self, path: str, **kwargs: object) -> httpx.Response:
86
+ """GET *path* with bearer auth."""
87
+ return httpx.get(f"{self.base_url}{path}", headers=self._headers(), **kwargs) # type: ignore[arg-type]
88
+
89
+ def post(self, path: str, **kwargs: object) -> httpx.Response:
90
+ """POST *path* with bearer auth."""
91
+ return httpx.post(f"{self.base_url}{path}", headers=self._headers(), **kwargs) # type: ignore[arg-type]
92
+
93
+ def post_unauth(self, path: str, **kwargs: object) -> httpx.Response:
94
+ """POST to *path* without an Authorization header.
95
+
96
+ Suitable for endpoints that require no auth (e.g. /auth/cli/callback).
97
+ """
98
+ return httpx.post(f"{self.base_url}{path}", timeout=15, **kwargs) # type: ignore[arg-type]
@@ -0,0 +1 @@
1
+ # simulo_cli.auth — credentials storage helpers
@@ -0,0 +1,57 @@
1
+ """Credentials file helpers — ~/.simulo/credentials (mode 0600).
2
+
3
+ Credentials file schema (version 1):
4
+ {
5
+ "version": 1,
6
+ "access_token": "<jwt>",
7
+ "refresh_token": "<opaque>",
8
+ "expires_at": "2026-04-24T15:00:00Z", # ISO 8601 UTC
9
+ "active_organization_id": "<org-uuid>",
10
+ "user_email": "user@example.com"
11
+ }
12
+
13
+ Wave 4 will call these helpers from the login/logout/refresh flow.
14
+ Field names here are canonical — do NOT rename without also updating
15
+ cli-auth/SKILL.md Contract Mapping table and every Wave 4 consumer.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import stat
22
+ from pathlib import Path
23
+
24
+ CREDENTIALS_PATH = Path.home() / ".simulo" / "credentials"
25
+
26
+
27
+ def save_credentials(data: dict) -> None:
28
+ """Write *data* to the credentials file with mode 0600.
29
+
30
+ Creates the parent directory (~/.simulo) with mode 0700 if absent.
31
+ """
32
+ CREDENTIALS_PATH.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
33
+ CREDENTIALS_PATH.write_text(json.dumps(data, indent=2))
34
+ CREDENTIALS_PATH.chmod(0o600)
35
+
36
+
37
+ def load_credentials() -> dict:
38
+ """Read and return the credentials dict.
39
+
40
+ Raises ``SystemExit`` if:
41
+ - the file does not exist (not logged in)
42
+ - the file has group- or world-readable permissions (security invariant)
43
+ """
44
+ if not CREDENTIALS_PATH.exists():
45
+ raise SystemExit("Not logged in. Run: simulo login")
46
+ mode = CREDENTIALS_PATH.stat().st_mode
47
+ if mode & (stat.S_IRWXG | stat.S_IRWXO):
48
+ raise SystemExit(
49
+ f"Credentials file {CREDENTIALS_PATH} has unsafe permissions. " f"Run: chmod 600 {CREDENTIALS_PATH}"
50
+ )
51
+ return json.loads(CREDENTIALS_PATH.read_text())
52
+
53
+
54
+ def clear_credentials() -> None:
55
+ """Remove the credentials file if it exists."""
56
+ if CREDENTIALS_PATH.exists():
57
+ CREDENTIALS_PATH.unlink()
@@ -0,0 +1,33 @@
1
+ """Root click group for the `simulo` CLI entry point.
2
+
3
+ Command surface (Wave 1 — all stubbed, Wave 4 wires implementations):
4
+
5
+ simulo login Authenticate via browser loopback / manual fallback
6
+ simulo logout Revoke refresh token and clear local credentials
7
+ simulo whoami Print current user + active org
8
+ simulo org use <id> Switch active organization context
9
+
10
+ Run ``simulo --help`` for the current command tree.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import click
16
+
17
+ from simulo_cli.commands.auth import login, logout, whoami
18
+ from simulo_cli.commands.org import org
19
+
20
+
21
+ @click.group()
22
+ @click.version_option(package_name="simulo")
23
+ def cli() -> None:
24
+ """Simulo CLI — manage projects, jobs, artifacts, and your account."""
25
+
26
+
27
+ # Auth commands
28
+ cli.add_command(login)
29
+ cli.add_command(logout)
30
+ cli.add_command(whoami)
31
+
32
+ # Org commands
33
+ cli.add_command(org)
@@ -0,0 +1 @@
1
+ # simulo_cli.commands — click command modules
@@ -0,0 +1,272 @@
1
+ """Auth commands — login, logout, whoami.
2
+
3
+ Wave 4: full implementations.
4
+
5
+ - login : PKCE loopback + manual fallback + POST /auth/cli/callback
6
+ - logout : load creds, POST /auth/logout (bearer), clear creds
7
+ - whoami : load creds, print user_email + active_organization_id
8
+
9
+ See .claude/skills/cli-auth/SKILL.md for the canonical specification.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import hashlib
16
+ import http.server
17
+ import secrets
18
+ import socket
19
+ import urllib.parse
20
+ import webbrowser
21
+
22
+ import click
23
+ import httpx
24
+
25
+ from simulo_cli.api.client import SimuloClient
26
+ from simulo_cli.auth.credentials import clear_credentials, load_credentials, save_credentials
27
+ from simulo_cli.config import get_settings
28
+
29
+ CANDIDATE_PORTS = (8421, 8422, 8423)
30
+ LOOPBACK_PATH = "/cli-callback"
31
+ LOGIN_TIMEOUT_SECONDS = 300
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # PKCE helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def _generate_pkce() -> tuple[str, str]:
40
+ """Return (code_verifier, code_challenge).
41
+
42
+ Verifier: 64 random bytes → base64url-encoded (≥86 chars, URL-safe).
43
+ Challenge: SHA-256(verifier) → base64url-encoded.
44
+ """
45
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode()
46
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
47
+ return verifier, challenge
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Loopback port binding
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def _bind_loopback() -> socket.socket | None:
56
+ """Try to bind 127.0.0.1 on each CANDIDATE_PORT in order.
57
+
58
+ Returns the bound socket on the first success, or None if all fail.
59
+ Binds to 127.0.0.1 ONLY — never 0.0.0.0.
60
+ """
61
+ for port in CANDIDATE_PORTS:
62
+ try:
63
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
64
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
65
+ sock.bind(("127.0.0.1", port))
66
+ sock.listen(1)
67
+ return sock
68
+ except OSError:
69
+ sock.close()
70
+ continue
71
+ return None
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Loopback code capture
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def _capture_code(server_socket: socket.socket, expected_state: str) -> str:
80
+ """Block until the browser redirects back, validate state, return code.
81
+
82
+ Renders a success page so the browser tab can be closed by the user.
83
+ The server socket is closed before the HTTPServer is created — the OS
84
+ will rebind on the same port because SO_REUSEADDR is set.
85
+ """
86
+ result: dict[str, str] = {}
87
+
88
+ class _Handler(http.server.BaseHTTPRequestHandler):
89
+ def log_message(self, *args: object, **kwargs: object) -> None: # type: ignore[override]
90
+ pass # suppress default stderr logging
91
+
92
+ def do_GET(self) -> None: # noqa: N802 — must match BaseHTTPRequestHandler
93
+ parsed = urllib.parse.urlparse(self.path)
94
+ if parsed.path != LOOPBACK_PATH:
95
+ self.send_error(404)
96
+ return
97
+ params = urllib.parse.parse_qs(parsed.query)
98
+ code = (params.get("code") or [""])[0]
99
+ state = (params.get("state") or [""])[0]
100
+
101
+ if not secrets.compare_digest(state, expected_state):
102
+ result["error"] = "state_mismatch"
103
+ self.send_error(400, "state mismatch")
104
+ return
105
+ if not code:
106
+ result["error"] = "missing_code"
107
+ self.send_error(400, "missing code")
108
+ return
109
+
110
+ result["code"] = code
111
+ self.send_response(200)
112
+ self.send_header("Content-Type", "text/html; charset=utf-8")
113
+ self.end_headers()
114
+ self.wfile.write(
115
+ b"<html><body>"
116
+ b"<h2>simulo: sign-in complete</h2>"
117
+ b"<p>You may close this window.</p>"
118
+ b"</body></html>"
119
+ )
120
+
121
+ host, port = server_socket.getsockname()
122
+ server_socket.close()
123
+ httpd = http.server.HTTPServer((host, port), _Handler)
124
+ httpd.timeout = LOGIN_TIMEOUT_SECONDS
125
+ httpd.handle_request()
126
+ httpd.server_close()
127
+
128
+ if "error" in result:
129
+ raise SystemExit(f"Login failed: {result['error']}")
130
+ if "code" not in result:
131
+ raise SystemExit("Login timed out. Run `simulo login` again.")
132
+ return result["code"]
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Manual fallback (headless / remote-SSH environments)
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ def _manual_fallback_code(authorize_url: str, expected_state: str) -> str:
141
+ """Print the authorize URL and prompt the user to paste back the code.
142
+
143
+ The page at console.simulo.ai/cli-callback shows the code and the first
144
+ 8 characters of the state as a fingerprint the user must confirm here
145
+ (defense against shoulder-surf token substitution).
146
+ """
147
+ click.echo(
148
+ "\nCould not open a local browser callback port.\n"
149
+ "Open this URL in a browser on another machine:\n\n"
150
+ f" {authorize_url}\n\n"
151
+ "After signing in, the page will display a short code and an 8-character\n"
152
+ "state fingerprint. Paste both below.\n"
153
+ )
154
+
155
+ code = click.prompt("Paste the code").strip()
156
+ # Ask the user to type what they saw on the page — NOT what the CLI generated.
157
+ # This is the out-of-band state check: two independent channels must agree.
158
+ entered_fingerprint = click.prompt("Enter the 8-character fingerprint shown on the page").strip()
159
+
160
+ state_fingerprint = expected_state[:8]
161
+ if not secrets.compare_digest(entered_fingerprint, state_fingerprint):
162
+ raise SystemExit("Login failed: state mismatch")
163
+
164
+ return code
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Commands
169
+ # ---------------------------------------------------------------------------
170
+
171
+
172
+ @click.command()
173
+ def login() -> None:
174
+ """Authenticate the CLI via browser (loopback or manual fallback)."""
175
+ settings = get_settings()
176
+
177
+ # Guard: an empty client ID causes a silent 401 after the full OAuth round-trip
178
+ # (Cognito accepts blank client_id but the control plane rejects the token).
179
+ # Fail early with a clear message rather than a confusing post-redirect 401.
180
+ if not settings.cli_app_client_id:
181
+ raise SystemExit(
182
+ "SIMULO_CLI_APP_CLIENT_ID is not set.\n"
183
+ " For staging: SIMULO_ENV=staging simulo login\n"
184
+ " For dev: set SIMULO_CLI_APP_CLIENT_ID=<client-id> (see .env.example)\n"
185
+ "See: simulo-devkit/packages/simulo/cli/README.md"
186
+ )
187
+
188
+ verifier, challenge = _generate_pkce()
189
+ state = secrets.token_urlsafe(32)
190
+
191
+ loopback = _bind_loopback()
192
+ if loopback is not None:
193
+ _, port = loopback.getsockname()
194
+ redirect_uri = f"http://127.0.0.1:{port}{LOOPBACK_PATH}"
195
+ else:
196
+ redirect_uri = f"{settings.console_base_url}{LOOPBACK_PATH}"
197
+
198
+ authorize_url = f"{settings.cognito_hosted_ui}/oauth2/authorize?" + urllib.parse.urlencode(
199
+ {
200
+ "client_id": settings.cli_app_client_id,
201
+ "response_type": "code",
202
+ "scope": "openid email profile",
203
+ "redirect_uri": redirect_uri,
204
+ "state": state,
205
+ "code_challenge": challenge,
206
+ "code_challenge_method": "S256",
207
+ }
208
+ )
209
+
210
+ if loopback is not None:
211
+ click.echo("Opening your browser to sign in…")
212
+ webbrowser.open(authorize_url)
213
+ code = _capture_code(loopback, state)
214
+ else:
215
+ code = _manual_fallback_code(authorize_url, state)
216
+
217
+ client = SimuloClient()
218
+ # code_verifier is intentionally NOT logged; it must not appear in any log.
219
+ resp = client.post_unauth(
220
+ "/auth/cli/callback",
221
+ json={
222
+ "code": code,
223
+ "code_verifier": verifier,
224
+ "redirect_uri": redirect_uri,
225
+ },
226
+ )
227
+
228
+ if resp.status_code != 200:
229
+ try:
230
+ err: dict = (resp.json() or {}).get("error") or {}
231
+ except (ValueError, AttributeError):
232
+ err = {}
233
+ raise SystemExit(f"Login failed: {err.get('message') or err.get('code') or 'unknown error'}")
234
+
235
+ token = resp.json()
236
+ # Explicit mapping — never save the raw response dict.
237
+ # Note: response uses 'organization_id'; credentials file uses 'active_organization_id'.
238
+ creds = {
239
+ "version": 1,
240
+ "access_token": token["access_token"],
241
+ "refresh_token": token["refresh_token"],
242
+ "expires_at": token["expires_at"],
243
+ "user_email": token["user_email"],
244
+ "active_organization_id": token["organization_id"],
245
+ }
246
+ save_credentials(creds)
247
+ click.echo(f"Logged in as {creds['user_email']} (org: {creds['active_organization_id']})")
248
+
249
+
250
+ @click.command()
251
+ def logout() -> None:
252
+ """Clear the local authenticated session and revoke the refresh token."""
253
+ try:
254
+ creds = load_credentials()
255
+ client = SimuloClient()
256
+ # Server response is intentionally ignored — per use case Alt Flow 1,
257
+ # logout always clears local credentials regardless of whether server-side
258
+ # Cognito revocation succeeds. Network errors are also swallowed for the
259
+ # same reason: the user must always be able to reach the signed-out state.
260
+ client.post("/auth/logout", json={"refresh_token": creds.get("refresh_token")})
261
+ except (SystemExit, httpx.HTTPError):
262
+ pass # Not logged in, session expired, or network error — still clear local state
263
+ clear_credentials()
264
+ click.echo("Logged out.")
265
+
266
+
267
+ @click.command()
268
+ def whoami() -> None:
269
+ """Show the current authenticated identity."""
270
+ creds = load_credentials()
271
+ click.echo(f"Email: {creds['user_email']}")
272
+ click.echo(f"Org: {creds['active_organization_id']}")
@@ -0,0 +1,29 @@
1
+ """Org commands — org use.
2
+
3
+ Wave 4: full implementation.
4
+
5
+ - org use <id>: load creds, set active_organization_id, save creds
6
+
7
+ See .claude/skills/cli-auth/SKILL.md §simulo org use for the specification.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import click
13
+
14
+ from simulo_cli.auth.credentials import load_credentials, save_credentials
15
+
16
+
17
+ @click.group()
18
+ def org() -> None:
19
+ """Manage organization context."""
20
+
21
+
22
+ @org.command("use")
23
+ @click.argument("organization_id")
24
+ def org_use(organization_id: str) -> None:
25
+ """Switch the active organization context to ORGANIZATION_ID."""
26
+ creds = load_credentials()
27
+ creds["active_organization_id"] = organization_id
28
+ save_credentials(creds)
29
+ click.echo(f"Active org set to: {organization_id}")