simulo 0.2.6__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.
- simulo-0.6.0/LICENSE +29 -0
- simulo-0.6.0/PKG-INFO +81 -0
- simulo-0.6.0/README.md +38 -0
- simulo-0.6.0/cli/src/simulo_cli/__init__.py +1 -0
- simulo-0.6.0/cli/src/simulo_cli/__main__.py +6 -0
- simulo-0.6.0/cli/src/simulo_cli/api/__init__.py +1 -0
- simulo-0.6.0/cli/src/simulo_cli/api/client.py +98 -0
- simulo-0.6.0/cli/src/simulo_cli/auth/__init__.py +1 -0
- simulo-0.6.0/cli/src/simulo_cli/auth/credentials.py +57 -0
- simulo-0.6.0/cli/src/simulo_cli/cli.py +33 -0
- simulo-0.6.0/cli/src/simulo_cli/commands/__init__.py +1 -0
- simulo-0.6.0/cli/src/simulo_cli/commands/auth.py +272 -0
- simulo-0.6.0/cli/src/simulo_cli/commands/org.py +29 -0
- simulo-0.6.0/cli/src/simulo_cli/config.py +138 -0
- simulo-0.6.0/pyproject.toml +185 -0
- simulo-0.6.0/sdk/src/simulo/__init__.py +162 -0
- simulo-0.6.0/sdk/src/simulo/backend/__init__.py +1 -0
- simulo-0.6.0/sdk/src/simulo/backend/isaaclab/__init__.py +5 -0
- simulo-0.6.0/sdk/src/simulo/backend/isaaclab/asset_registry.py +365 -0
- simulo-0.6.0/sdk/src/simulo/backend/isaaclab/backend.py +227 -0
- simulo-0.6.0/sdk/src/simulo/backend/isaaclab/scene_builder.py +2237 -0
- simulo-0.6.0/sdk/src/simulo/backend/isaaclab/stream_adapter.py +182 -0
- simulo-0.6.0/sdk/src/simulo/core/__init__.py +88 -0
- simulo-0.6.0/sdk/src/simulo/core/actuator.py +314 -0
- simulo-0.6.0/sdk/src/simulo/core/asset.py +63 -0
- simulo-0.6.0/sdk/src/simulo/core/controller.py +1012 -0
- simulo-0.6.0/sdk/src/simulo/core/entity.py +180 -0
- simulo-0.6.0/sdk/src/simulo/core/env.py +553 -0
- simulo-0.6.0/sdk/src/simulo/core/light.py +84 -0
- simulo-0.6.0/sdk/src/simulo/core/materials.py +61 -0
- simulo-0.6.0/sdk/src/simulo/core/math_utils.py +74 -0
- simulo-0.6.0/sdk/src/simulo/core/observations.py +481 -0
- simulo-0.6.0/sdk/src/simulo/core/physics.py +154 -0
- simulo-0.6.0/sdk/src/simulo/core/player.py +604 -0
- simulo-0.6.0/sdk/src/simulo/core/policy.py +245 -0
- simulo-0.6.0/sdk/src/simulo/core/pose.py +99 -0
- simulo-0.6.0/sdk/src/simulo/core/primitives.py +132 -0
- simulo-0.6.0/sdk/src/simulo/core/rewards.py +798 -0
- simulo-0.6.0/sdk/src/simulo/core/robot.py +1041 -0
- simulo-0.6.0/sdk/src/simulo/core/scene.py +462 -0
- simulo-0.6.0/sdk/src/simulo/core/sensor.py +923 -0
- simulo-0.6.0/sdk/src/simulo/core/task.py +215 -0
- simulo-0.6.0/sdk/src/simulo/core/terminations.py +326 -0
- simulo-0.6.0/sdk/src/simulo/core/terrain.py +380 -0
- simulo-0.6.0/sdk/src/simulo/core/trainer.py +437 -0
- simulo-0.6.0/sdk/src/simulo/core/visual.py +302 -0
- simulo-0.6.0/sdk/src/simulo/core/world.py +138 -0
- simulo-0.6.0/sdk/src/simulo/scenario.py +196 -0
- simulo-0.6.0/sdk/src/simulo/streaming/__init__.py +31 -0
- simulo-0.6.0/sdk/src/simulo/streaming/adapter.py +209 -0
- simulo-0.6.0/sdk/src/simulo/streaming/config.py +90 -0
- simulo-0.6.0/sdk/src/simulo/streaming/types.py +103 -0
- {simulo-0.2.6 → simulo-0.6.0}/setup.cfg +4 -4
- simulo-0.6.0/simulo.egg-info/PKG-INFO +81 -0
- simulo-0.6.0/simulo.egg-info/SOURCES.txt +59 -0
- simulo-0.6.0/simulo.egg-info/entry_points.txt +2 -0
- simulo-0.6.0/simulo.egg-info/requires.txt +23 -0
- simulo-0.6.0/simulo.egg-info/top_level.txt +2 -0
- simulo-0.2.6/LICENSE +0 -1
- simulo-0.2.6/MANIFEST.in +0 -1
- simulo-0.2.6/PKG-INFO +0 -27
- simulo-0.2.6/README.rst +0 -6
- simulo-0.2.6/setup.py +0 -47
- simulo-0.2.6/simulo/auth/authenticator.py +0 -56
- simulo-0.2.6/simulo/cli/__init__.py +0 -0
- simulo-0.2.6/simulo/cli/runner.py +0 -80
- simulo-0.2.6/simulo/config.py +0 -13
- simulo-0.2.6/simulo/simulomath.py +0 -27
- simulo-0.2.6/simulo/upload/__init__.py +0 -0
- simulo-0.2.6/simulo/upload/data_upload.py +0 -259
- simulo-0.2.6/simulo/utils/__init__.py +0 -0
- simulo-0.2.6/simulo/utils/json_transformations.py +0 -27
- simulo-0.2.6/simulo/utils/unique_id.py +0 -4
- simulo-0.2.6/simulo.egg-info/PKG-INFO +0 -27
- simulo-0.2.6/simulo.egg-info/SOURCES.txt +0 -22
- simulo-0.2.6/simulo.egg-info/entry_points.txt +0 -3
- simulo-0.2.6/simulo.egg-info/requires.txt +0 -5
- simulo-0.2.6/simulo.egg-info/top_level.txt +0 -1
- /simulo-0.2.6/simulo/__init__.py → /simulo-0.6.0/cli/src/simulo_cli/py.typed +0 -0
- /simulo-0.2.6/simulo/auth/__init__.py → /simulo-0.6.0/sdk/src/simulo/py.typed +0 -0
- {simulo-0.2.6 → 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 @@
|
|
|
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}")
|