lucen-cli 0.1.0__tar.gz → 0.1.2__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.
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/PKG-INFO +29 -5
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/README.md +27 -3
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/pyproject.toml +2 -2
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen/hub.py +3 -3
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/__init__.py +1 -1
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/client.py +24 -4
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_quickstart.py +3 -3
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_robot.py +67 -2
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_runs.py +21 -5
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_serve.py +60 -5
- lucen_cli-0.1.2/src/lucen_cli/extras.py +29 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/hf_dataset.py +90 -16
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/main.py +28 -3
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/output.py +12 -1
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/push.py +2 -0
- lucen_cli-0.1.2/src/lucen_cli/rl/mirror.py +188 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rl/ppo.py +24 -1
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rl/tasks.py +34 -2
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rl/vecenv.py +104 -1
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/__init__.py +2 -1
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/checks.py +12 -1
- lucen_cli-0.1.2/src/lucen_cli/robot/collada.py +338 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/draft.py +85 -6
- lucen_cli-0.1.2/src/lucen_cli/robot/gains.py +191 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/model.py +30 -1
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/urdf.py +221 -11
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot_assets.py +2 -3
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/engine.py +4 -11
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/obs.py +49 -9
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/plant.py +9 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/posture.py +46 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/worker.py +2 -2
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/lerobot_backend.py +3 -2
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/worker.py +37 -4
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/templates/ppo_mujoco.py +151 -7
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/templates/smolvla_lora.py +311 -32
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/vla_manifest.py +7 -1
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/LICENSE +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen/__init__.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen/errors.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen/progress.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen/py.typed +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen/results.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/checkpoint_dir.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/checkpoint_publisher.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_auth.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_datasets.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_holds.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_hub.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_model.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_org.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_repo.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_rollout.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/cmd_worker.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/config.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/context.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/contract_derive.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/embodiment.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/errors.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/gate_scorer.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/group.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/live.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/local.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/model_card.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/offscreen.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/onnx_manifest.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/onnx_writer.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/ort_quiet.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/progress.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/pull.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rl/__init__.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/confine.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/hf.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/hub.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/intake.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/pose.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/publish.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/refs.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/thumbnail.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/robot/worker.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/__init__.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/assets.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/conditions.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/contract.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/harness.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/rollout/video.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/__init__.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/msgpack_numpy.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/policy.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/server.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/session.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/stub_skill.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/serve/vla.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/sim_words.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/state.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/templates/__init__.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/templates/ppo_isaac.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/templates/stub_ppo.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/templates/stub_skill.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/tfevents.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/training_curve.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/transfer.py +0 -0
- {lucen_cli-0.1.0 → lucen_cli-0.1.2}/src/lucen_cli/worker.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lucen-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: MouseMouse CLI (`lucen`): push robot data, train and sim-test policies, hand them to a robot through a consent gate
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Requires-Dist: typer>=0.20
|
|
8
8
|
Requires-Dist: rich>=14.0
|
|
9
9
|
Requires-Dist: httpx>=0.28
|
|
10
|
-
Requires-Dist: lucen-sdk>=0.1.
|
|
10
|
+
Requires-Dist: lucen-sdk>=0.1.1,<0.2
|
|
11
11
|
Requires-Dist: pyyaml>=6.0
|
|
12
12
|
Requires-Dist: huggingface-hub>=1.0,<2
|
|
13
13
|
Requires-Dist: mujoco==3.12.0 ; extra == 'rl'
|
|
@@ -63,9 +63,10 @@ Extras bring the engines some commands need; everything else stays a few MB:
|
|
|
63
63
|
| `uv tool install "lucen-cli[serve]"` | ONNX Runtime, websockets, msgpack | `lucen serve` (a hosted-endpoint server on your GPU box) |
|
|
64
64
|
| `uv tool install "lucen-cli[vla]"` | LeRobot, torch, PEFT | the `smolvla_lora` template |
|
|
65
65
|
|
|
66
|
-
A MouseMouse hub also serves its own copy of these
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
A MouseMouse hub built from its API image also serves its own copy of these
|
|
67
|
+
wheels for machines that cannot reach PyPI, built from the hub's own commit:
|
|
68
|
+
`curl -fsSL <hub API>/install.sh | sh -s -- cli` installs from there and points
|
|
69
|
+
the CLI at that hub.
|
|
69
70
|
|
|
70
71
|
## First steps
|
|
71
72
|
|
|
@@ -110,4 +111,27 @@ The robot side of the consent gate is
|
|
|
110
111
|
[`lucen-device`](https://pypi.org/project/lucen-device/); the hub's tools for an
|
|
111
112
|
AI agent are [`lucen-mcp`](https://pypi.org/project/lucen-mcp/).
|
|
112
113
|
|
|
114
|
+
## Changelog
|
|
115
|
+
|
|
116
|
+
- **0.1.2** (2026-10-18): `lucen robot init` takes the URDFs people actually
|
|
117
|
+
have: COLLADA (`.dae`) meshes are converted to STL, `package://` paths deeper
|
|
118
|
+
than the uploaded folder resolve, a xacro file is refused with the command
|
|
119
|
+
that expands it, and a model file you shipped is never overwritten. A
|
|
120
|
+
floating robot gets a floor and its declared pose's height, a model with no
|
|
121
|
+
gains gets a starting kp/kd found in simulation, and each leg's grounded leaf
|
|
122
|
+
is proposed as its foot. `lucen push --kind dataset` writes a LeRobot
|
|
123
|
+
dataset's episode and frame counts, as `lucen datasets import` does, and the
|
|
124
|
+
import without a terminal names the line to run with `--yes`. `lucen runs
|
|
125
|
+
create --max-hours`; `lucen endpoints estimate --owner`; a long wait is told
|
|
126
|
+
as a UTC time. `smolvla_lora` trains on an `hf://` dataset, refuses a base
|
|
127
|
+
checkpoint of another family, and stamps a policy for its robot only when the
|
|
128
|
+
dataset matches it. `lucen serve` tells the hub why it stopped. Needs
|
|
129
|
+
`lucen-sdk` 0.1.1.
|
|
130
|
+
- **0.1.1** (2026-10-13): the built-in `ppo_mujoco` trainer's `velocity` preset
|
|
131
|
+
trains under the sim tests' own conditions (the action delay, the rate limit
|
|
132
|
+
and the pushes), so a quick run on Laika stands at about 2° of tilt instead
|
|
133
|
+
of a crouch; ONNX Runtime's start-up messages stay out of a run's log; every
|
|
134
|
+
"install the extra" hint names the PyPI line.
|
|
135
|
+
- **0.1.0**: the first release on PyPI.
|
|
136
|
+
|
|
113
137
|
Apache-2.0. © 2026 Lucen Robotics.
|
|
@@ -27,9 +27,10 @@ Extras bring the engines some commands need; everything else stays a few MB:
|
|
|
27
27
|
| `uv tool install "lucen-cli[serve]"` | ONNX Runtime, websockets, msgpack | `lucen serve` (a hosted-endpoint server on your GPU box) |
|
|
28
28
|
| `uv tool install "lucen-cli[vla]"` | LeRobot, torch, PEFT | the `smolvla_lora` template |
|
|
29
29
|
|
|
30
|
-
A MouseMouse hub also serves its own copy of these
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
A MouseMouse hub built from its API image also serves its own copy of these
|
|
31
|
+
wheels for machines that cannot reach PyPI, built from the hub's own commit:
|
|
32
|
+
`curl -fsSL <hub API>/install.sh | sh -s -- cli` installs from there and points
|
|
33
|
+
the CLI at that hub.
|
|
33
34
|
|
|
34
35
|
## First steps
|
|
35
36
|
|
|
@@ -74,4 +75,27 @@ The robot side of the consent gate is
|
|
|
74
75
|
[`lucen-device`](https://pypi.org/project/lucen-device/); the hub's tools for an
|
|
75
76
|
AI agent are [`lucen-mcp`](https://pypi.org/project/lucen-mcp/).
|
|
76
77
|
|
|
78
|
+
## Changelog
|
|
79
|
+
|
|
80
|
+
- **0.1.2** (2026-10-18): `lucen robot init` takes the URDFs people actually
|
|
81
|
+
have: COLLADA (`.dae`) meshes are converted to STL, `package://` paths deeper
|
|
82
|
+
than the uploaded folder resolve, a xacro file is refused with the command
|
|
83
|
+
that expands it, and a model file you shipped is never overwritten. A
|
|
84
|
+
floating robot gets a floor and its declared pose's height, a model with no
|
|
85
|
+
gains gets a starting kp/kd found in simulation, and each leg's grounded leaf
|
|
86
|
+
is proposed as its foot. `lucen push --kind dataset` writes a LeRobot
|
|
87
|
+
dataset's episode and frame counts, as `lucen datasets import` does, and the
|
|
88
|
+
import without a terminal names the line to run with `--yes`. `lucen runs
|
|
89
|
+
create --max-hours`; `lucen endpoints estimate --owner`; a long wait is told
|
|
90
|
+
as a UTC time. `smolvla_lora` trains on an `hf://` dataset, refuses a base
|
|
91
|
+
checkpoint of another family, and stamps a policy for its robot only when the
|
|
92
|
+
dataset matches it. `lucen serve` tells the hub why it stopped. Needs
|
|
93
|
+
`lucen-sdk` 0.1.1.
|
|
94
|
+
- **0.1.1** (2026-10-13): the built-in `ppo_mujoco` trainer's `velocity` preset
|
|
95
|
+
trains under the sim tests' own conditions (the action delay, the rate limit
|
|
96
|
+
and the pushes), so a quick run on Laika stands at about 2° of tilt instead
|
|
97
|
+
of a crouch; ONNX Runtime's start-up messages stay out of a run's log; every
|
|
98
|
+
"install the extra" hint names the PyPI line.
|
|
99
|
+
- **0.1.0**: the first release on PyPI.
|
|
100
|
+
|
|
77
101
|
Apache-2.0. © 2026 Lucen Robotics.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lucen-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.2"
|
|
4
4
|
description = "MouseMouse CLI (`lucen`): push robot data, train and sim-test policies, hand them to a robot through a consent gate"
|
|
5
5
|
# R1: `readme`, `license`, `license-files` and `[project.urls]` are what PyPI
|
|
6
6
|
# renders; the LICENSE beside this file is Apache-2.0 verbatim (docs/DECISIONS.md "R1").
|
|
@@ -18,7 +18,7 @@ dependencies = [
|
|
|
18
18
|
# Requires-Dist, where `[tool.uv.sources]` below does not reach, and PyPI has
|
|
19
19
|
# to resolve it. The generated client is 0.x, so a minor bump is its breaking
|
|
20
20
|
# signal: the CLI follows the SDK within one minor.
|
|
21
|
-
"lucen-sdk>=0.1.
|
|
21
|
+
"lucen-sdk>=0.1.1,<0.2",
|
|
22
22
|
# M7-RL: `lucen runs create -f recipe.yaml`. The stub template stays stdlib
|
|
23
23
|
# only and emits its own YAML; only the recipe *reader* needs a parser.
|
|
24
24
|
"pyyaml>=6.0",
|
|
@@ -72,6 +72,7 @@ from lucen_cli.config import (
|
|
|
72
72
|
)
|
|
73
73
|
from lucen_cli.errors import CLIError
|
|
74
74
|
from lucen_cli.errors import HubUnreachable as _CLIHubUnreachable
|
|
75
|
+
from lucen_cli.extras import install_line, pip_line
|
|
75
76
|
from lucen_cli.progress import Reporter
|
|
76
77
|
from lucen_cli.pull import run_pull
|
|
77
78
|
from lucen_cli.push import run_push
|
|
@@ -85,9 +86,8 @@ NO_HUB_URL_FIX_PYTHON = (
|
|
|
85
86
|
|
|
86
87
|
#: The sentence `hub.robots` and a local rollout refuse with when MuJoCo is absent.
|
|
87
88
|
ENGINE_FIX = (
|
|
88
|
-
"Install the rollout extra into this environment: "
|
|
89
|
-
|
|
90
|
-
'(or, from a checkout: uv pip install ./packages/sdk/py "./packages/cli[rollout]").'
|
|
89
|
+
f"Install the rollout extra into this environment: {pip_line('rollout')} "
|
|
90
|
+
f"(as a tool on the PATH: {install_line('rollout')})."
|
|
91
91
|
)
|
|
92
92
|
|
|
93
93
|
#: `recipe` as a script hands it over: a mapping, or the path of a YAML/JSON file.
|
|
@@ -28,6 +28,7 @@ import re
|
|
|
28
28
|
import time
|
|
29
29
|
from collections.abc import Callable, Iterator, Sequence
|
|
30
30
|
from dataclasses import dataclass
|
|
31
|
+
from datetime import UTC, datetime, timedelta
|
|
31
32
|
from http import HTTPStatus
|
|
32
33
|
from types import TracebackType
|
|
33
34
|
from typing import Any, Final
|
|
@@ -172,6 +173,7 @@ from lucen_sdk.models import (
|
|
|
172
173
|
EndpointPage,
|
|
173
174
|
EndpointReport,
|
|
174
175
|
EndpointReportStatus,
|
|
176
|
+
EndpointServerStopReason,
|
|
175
177
|
EndpointSession,
|
|
176
178
|
EndpointSessionOpen,
|
|
177
179
|
EndpointSessionOpened,
|
|
@@ -354,6 +356,18 @@ class RepoTarget:
|
|
|
354
356
|
return cls(owner=owner, slug=slug)
|
|
355
357
|
|
|
356
358
|
|
|
359
|
+
def _retry_hint(retry: str | None) -> str:
|
|
360
|
+
"""When to try again after a 429. A spend cap's or an allowance's `Retry-After` runs to the
|
|
361
|
+
1st of next month, and "Wait 1189678 s" was the line a person read (T2)."""
|
|
362
|
+
seconds = int(retry) if retry and retry.strip().isdigit() else None
|
|
363
|
+
if seconds is None:
|
|
364
|
+
return "Wait a moment and run it again."
|
|
365
|
+
if seconds <= 3600:
|
|
366
|
+
return f"Wait {seconds} s and run it again."
|
|
367
|
+
when = datetime.now(UTC) + timedelta(seconds=seconds)
|
|
368
|
+
return f"Try again after {when:%Y-%m-%d %H:%M} UTC, or do what the hub's sentence above says."
|
|
369
|
+
|
|
370
|
+
|
|
357
371
|
class HubClient:
|
|
358
372
|
"""Thin, typed, error-translating wrapper over the generated SDK."""
|
|
359
373
|
|
|
@@ -564,9 +578,7 @@ class HubClient:
|
|
|
564
578
|
elif status == 429:
|
|
565
579
|
retry = response.headers.get("retry-after")
|
|
566
580
|
message = f"{action}: rate limited by the hub."
|
|
567
|
-
hint = (
|
|
568
|
-
f"Wait {retry} s and run it again." if retry else "Wait a moment and run it again."
|
|
569
|
-
)
|
|
581
|
+
hint = _retry_hint(retry)
|
|
570
582
|
elif status == 503:
|
|
571
583
|
# A deliberate refusal ("this deployment cannot serve that"), not
|
|
572
584
|
# an outage: the detail is the answer, and retrying will not help.
|
|
@@ -1432,6 +1444,7 @@ class HubClient:
|
|
|
1432
1444
|
sessions: Sequence[dict[str, Any]] = (),
|
|
1433
1445
|
error: str | None = None,
|
|
1434
1446
|
log_chunk: str | None = None,
|
|
1447
|
+
stop_reason: str | None = None,
|
|
1435
1448
|
) -> Endpoint:
|
|
1436
1449
|
states = []
|
|
1437
1450
|
for item in sessions:
|
|
@@ -1453,6 +1466,7 @@ class HubClient:
|
|
|
1453
1466
|
sessions=states if states else UNSET,
|
|
1454
1467
|
error=error if error is not None else UNSET,
|
|
1455
1468
|
log_chunk=log_chunk if log_chunk else UNSET,
|
|
1469
|
+
stop_reason=EndpointServerStopReason(stop_reason) if stop_reason else UNSET,
|
|
1456
1470
|
)
|
|
1457
1471
|
action = f"Reporting on endpoint {endpoint_id}"
|
|
1458
1472
|
# F1: one attempt, translated — a hub that does not answer is an `ApiError`
|
|
@@ -1589,13 +1603,19 @@ class HubClient:
|
|
|
1589
1603
|
total_frames: int,
|
|
1590
1604
|
fps: float | None = None,
|
|
1591
1605
|
robot_type: str | None = None,
|
|
1606
|
+
recorded_policy: str | None = None,
|
|
1592
1607
|
) -> DatasetMeta:
|
|
1593
|
-
"""`PUT /v1/datasets/{owner}/{repo}/meta` — the numbers `meta/info.json` declares.
|
|
1608
|
+
"""`PUT /v1/datasets/{owner}/{repo}/meta` — the numbers `meta/info.json` declares.
|
|
1609
|
+
|
|
1610
|
+
V3: `recorded_policy` (`owner/slug`) names the policy the robot ran while
|
|
1611
|
+
the data was recorded; left out, the hub records none.
|
|
1612
|
+
"""
|
|
1594
1613
|
body = DatasetMetaPut(
|
|
1595
1614
|
episode_count=episode_count,
|
|
1596
1615
|
total_frames=total_frames,
|
|
1597
1616
|
fps=fps,
|
|
1598
1617
|
robot_type=robot_type,
|
|
1618
|
+
recorded_policy=recorded_policy,
|
|
1599
1619
|
)
|
|
1600
1620
|
response = put_dataset_meta_api.sync_detailed(
|
|
1601
1621
|
target.owner, target.slug, client=self._sdk, body=body
|
|
@@ -135,7 +135,7 @@ def plan_robot_file(path: Path) -> list[Step]:
|
|
|
135
135
|
Step(
|
|
136
136
|
f"lucen runs create --template ppo_mujoco --robot you/{slug} --executor worker",
|
|
137
137
|
"Queue a PPO run on it; `lucen worker run` in a second terminal trains it "
|
|
138
|
-
"here, free (needs the rl extra: install `cli[rl]`).",
|
|
138
|
+
"here, free (needs the rl extra: install `lucen-cli[rl]`).",
|
|
139
139
|
)
|
|
140
140
|
)
|
|
141
141
|
return steps
|
|
@@ -151,7 +151,7 @@ def plan_robot_on_hub(robot: str) -> list[Step]:
|
|
|
151
151
|
Step(
|
|
152
152
|
"lucen worker run",
|
|
153
153
|
"In a second terminal: claims the run, trains on this machine (free) and "
|
|
154
|
-
"publishes the policy as a model repo (needs the rl extra: install `cli[rl]`).",
|
|
154
|
+
"publishes the policy as a model repo (needs the rl extra: install `lucen-cli[rl]`).",
|
|
155
155
|
),
|
|
156
156
|
Step(
|
|
157
157
|
f"lucen rollout {MODEL_PLACEHOLDER} --robot {target.full_name} --battery smoke --local",
|
|
@@ -205,7 +205,7 @@ def plan_no_file() -> list[Step]:
|
|
|
205
205
|
Step(
|
|
206
206
|
"lucen worker run",
|
|
207
207
|
"In a second terminal: claims it, trains here (free) and publishes the policy "
|
|
208
|
-
"(needs the rl extra: install `cli[rl]`).",
|
|
208
|
+
"(needs the rl extra: install `lucen-cli[rl]`).",
|
|
209
209
|
),
|
|
210
210
|
Step(
|
|
211
211
|
f"lucen rollout {MODEL_PLACEHOLDER} --robot lucen/laika --battery smoke --local",
|
|
@@ -237,7 +237,7 @@ def run_init(
|
|
|
237
237
|
from lucen_cli.robot import draft as drafting
|
|
238
238
|
from lucen_cli.robot import pose as poses
|
|
239
239
|
from lucen_cli.robot import urdf
|
|
240
|
-
from lucen_cli.robot.model import compile_file, facts_of
|
|
240
|
+
from lucen_cli.robot.model import compile_file, facts_of, rest_height
|
|
241
241
|
|
|
242
242
|
if action not in _ACTIONS:
|
|
243
243
|
raise RobotError("init.action", f"--action must be one of: {', '.join(_ACTIONS)}.")
|
|
@@ -306,6 +306,11 @@ def run_init(
|
|
|
306
306
|
)
|
|
307
307
|
if declared:
|
|
308
308
|
_check_declared_pose(declared, facts)
|
|
309
|
+
base_height_m = conversion.base_height_m if conversion is not None else None
|
|
310
|
+
if declared and facts.floating and (conversion is not None or facts.default_source == "qpos0"):
|
|
311
|
+
# The height a converted model was lifted to is the straight-leg one (qpos0);
|
|
312
|
+
# the declared pose is what policies start from, so rest THAT on the floor.
|
|
313
|
+
base_height_m = rest_height(model, facts, declared) or base_height_m
|
|
309
314
|
options = drafting.DraftOptions(
|
|
310
315
|
control_hz=control_hz,
|
|
311
316
|
action=action,
|
|
@@ -325,7 +330,7 @@ def run_init(
|
|
|
325
330
|
mjcf_path=model_path.relative_to(root_dir).as_posix(),
|
|
326
331
|
options=options,
|
|
327
332
|
extra_limits=conversion.limits if conversion is not None else None,
|
|
328
|
-
base_height_m=
|
|
333
|
+
base_height_m=base_height_m,
|
|
329
334
|
)
|
|
330
335
|
if conversion is not None:
|
|
331
336
|
for warning in conversion.warnings:
|
|
@@ -338,6 +343,7 @@ def run_init(
|
|
|
338
343
|
"inferred_mass_kg": warning.get("inferred_mass_kg"),
|
|
339
344
|
}
|
|
340
345
|
)
|
|
346
|
+
_propose_gains(built, root_dir, model, facts, declared)
|
|
341
347
|
card_path.write_text(
|
|
342
348
|
json.dumps(built.document(), indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
343
349
|
)
|
|
@@ -363,6 +369,65 @@ def run_init(
|
|
|
363
369
|
}
|
|
364
370
|
|
|
365
371
|
|
|
372
|
+
GAINS_FIELD = "interface.actuation[].kp / kd"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _propose_gains(
|
|
376
|
+
built: Any, root_dir: Path, model: Any, facts: ModelFacts, declared: dict[str, float] | None
|
|
377
|
+
) -> None:
|
|
378
|
+
"""T1: attach a searched starting gain to the blocking kp/kd entry, as a proposal.
|
|
379
|
+
|
|
380
|
+
The entry stays blocking: accepting the proposal is re-running init with its
|
|
381
|
+
flags (the web's "Use these gains" does exactly that), so nothing is decided
|
|
382
|
+
for the person. When no candidate passes, the entry says so instead.
|
|
383
|
+
"""
|
|
384
|
+
from lucen_cli.robot import gains
|
|
385
|
+
|
|
386
|
+
need = next((n for n in built.needs_you if n.get("field") == GAINS_FIELD), None)
|
|
387
|
+
if need is None:
|
|
388
|
+
return
|
|
389
|
+
pose = None
|
|
390
|
+
if not declared:
|
|
391
|
+
pose_need = next(
|
|
392
|
+
(n for n in built.needs_you if n.get("field") == "interface.default_pose"), None
|
|
393
|
+
)
|
|
394
|
+
if pose_need and isinstance(pose_need.get("suggestion"), dict):
|
|
395
|
+
pose = {str(k): float(v) for k, v in pose_need["suggestion"].items()}
|
|
396
|
+
try:
|
|
397
|
+
proposal = gains.propose(root_dir, built.card, model, facts, pose=pose)
|
|
398
|
+
except Exception as exc: # a proposal is help; it never fails init
|
|
399
|
+
need["why"] += f" (No starting gain was proposed: the search failed with {exc}.)"
|
|
400
|
+
return
|
|
401
|
+
if proposal is None:
|
|
402
|
+
need["why"] += (
|
|
403
|
+
" No uniform kp/kd up to 600 (with armature up to 0.1 kg·m²) held this model in "
|
|
404
|
+
"simulation, so there is no proposal: set gains per group."
|
|
405
|
+
)
|
|
406
|
+
return
|
|
407
|
+
line = gains.flags(proposal)
|
|
408
|
+
# `suggestion` is the flags (a string, as the hub's needs schema types it); `proposal`
|
|
409
|
+
# is the same values as numbers, for a page or an agent that fills them in.
|
|
410
|
+
need["suggestion"] = line
|
|
411
|
+
need["proposal"] = {
|
|
412
|
+
key: proposal[key] for key in ("kp", "kd", "armature") if proposal.get(key) is not None
|
|
413
|
+
}
|
|
414
|
+
if pose:
|
|
415
|
+
need["proposal"]["default_pose"] = pose
|
|
416
|
+
need["suggestion_source"] = (
|
|
417
|
+
"a PROPOSAL found by simulation, not read from the robot: the first uniform gain"
|
|
418
|
+
+ (" (with armature, which a URDF cannot carry)" if proposal.get("armature") else "")
|
|
419
|
+
+ " that passes `model.gains`, `stability.settle` and `stability.joint_response`"
|
|
420
|
+
+ (" at the proposed default pose" if pose else "")
|
|
421
|
+
+ f" ({proposal['candidates_tried']} candidate(s) simulated)"
|
|
422
|
+
)
|
|
423
|
+
need["settle"] = proposal.get("settle")
|
|
424
|
+
need["why"] += (
|
|
425
|
+
f" A starting point that held this model in simulation: {line}"
|
|
426
|
+
+ (" (with the proposed default pose)" if pose else "")
|
|
427
|
+
+ ". Check it against your motors, then pass it to `lucen robot init`."
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
366
431
|
def _check_declared_pose(declared: dict[str, float], facts: ModelFacts) -> None:
|
|
367
432
|
"""`--default-pose` names only the model's 1-DoF joints, each inside its range."""
|
|
368
433
|
known = {joint.name: joint for joint in facts.joints}
|
|
@@ -192,6 +192,16 @@ ParentOpt = Annotated[
|
|
|
192
192
|
OwnerOpt = Annotated[
|
|
193
193
|
str | None, typer.Option("--owner", help="Org slug to bill it to; default: you.")
|
|
194
194
|
]
|
|
195
|
+
MaxHoursOpt = Annotated[
|
|
196
|
+
float | None,
|
|
197
|
+
typer.Option(
|
|
198
|
+
"--max-hours",
|
|
199
|
+
min=0.01,
|
|
200
|
+
max=720,
|
|
201
|
+
help="With --template: the run's time limit. On Lucen cloud it is what the run "
|
|
202
|
+
"reserves against your free allowance (default 4 h).",
|
|
203
|
+
),
|
|
204
|
+
]
|
|
195
205
|
JsonOpt = Annotated[bool, typer.Option("--json", help="Machine-readable output.")]
|
|
196
206
|
IdempotencyOpt = Annotated[
|
|
197
207
|
str | None,
|
|
@@ -266,15 +276,16 @@ def create_run(
|
|
|
266
276
|
template: TemplateOpt = None,
|
|
267
277
|
robot: RobotOpt = None,
|
|
268
278
|
executor: ExecutorOpt = None,
|
|
279
|
+
max_hours: MaxHoursOpt = None,
|
|
269
280
|
parent: ParentOpt = None,
|
|
270
281
|
owner: OwnerOpt = None,
|
|
271
282
|
idempotency_key: IdempotencyOpt = None,
|
|
272
283
|
as_json: JsonOpt = False,
|
|
273
284
|
) -> None:
|
|
274
|
-
"""Submit a training run
|
|
285
|
+
"""Submit a training run: on your machine (`lucen worker run`), or on Lucen cloud."""
|
|
275
286
|
state = ctx.ensure_object(AppState)
|
|
276
287
|
recipe, file_owner, file_parent = load_recipe(
|
|
277
|
-
file, template=template, robot=robot, executor=executor
|
|
288
|
+
file, template=template, robot=robot, executor=executor, max_hours=max_hours
|
|
278
289
|
)
|
|
279
290
|
try:
|
|
280
291
|
client, run, created, web = _submit(
|
|
@@ -298,15 +309,18 @@ def load_recipe(
|
|
|
298
309
|
template: str | None,
|
|
299
310
|
robot: str | None,
|
|
300
311
|
executor: str | None,
|
|
312
|
+
max_hours: float | None = None,
|
|
301
313
|
) -> tuple[dict[str, Any], str | None, str | None]:
|
|
302
314
|
"""A recipe from `-f FILE` or from `--template [--robot] [--executor]`, never both."""
|
|
303
315
|
if file is not None and template is not None:
|
|
304
316
|
raise CLIError("Give either -f RECIPE or --template, not both.")
|
|
305
317
|
if file is not None:
|
|
306
|
-
if robot is not None or executor is not None:
|
|
318
|
+
if robot is not None or executor is not None or max_hours is not None:
|
|
307
319
|
raise CLIError(
|
|
308
|
-
"--robot and --
|
|
309
|
-
|
|
320
|
+
"--robot, --executor and --max-hours go with --template; a recipe file "
|
|
321
|
+
"names its own.",
|
|
322
|
+
hint="Put `robot:`, `executor:` and `max_hours:` in the file, or drop -f and "
|
|
323
|
+
"use --template.",
|
|
310
324
|
)
|
|
311
325
|
return load_recipe_file(file)
|
|
312
326
|
if template is None:
|
|
@@ -319,6 +333,8 @@ def load_recipe(
|
|
|
319
333
|
recipe["robot"] = robot
|
|
320
334
|
if executor is not None:
|
|
321
335
|
recipe["executor"] = executor
|
|
336
|
+
if max_hours is not None:
|
|
337
|
+
recipe["max_hours"] = max_hours
|
|
322
338
|
return recipe, None, None
|
|
323
339
|
|
|
324
340
|
|
|
@@ -19,6 +19,7 @@ import typer
|
|
|
19
19
|
|
|
20
20
|
from lucen_cli.context import AppState
|
|
21
21
|
from lucen_cli.errors import CLIError
|
|
22
|
+
from lucen_cli.extras import install_line
|
|
22
23
|
from lucen_cli.group import LucenGroup
|
|
23
24
|
from lucen_cli.output import emit_json, stdout
|
|
24
25
|
|
|
@@ -38,9 +39,7 @@ def _need_serve_extra() -> None:
|
|
|
38
39
|
except ImportError as exc: # pragma: no cover - depends on the install
|
|
39
40
|
raise CLIError(
|
|
40
41
|
f"`lucen serve` needs the `serve` extra ({exc.name} is not installed).",
|
|
41
|
-
hint=(
|
|
42
|
-
'uv tool install --from "./packages/cli[serve]" --with ./packages/sdk/py lucen-cli'
|
|
43
|
-
),
|
|
42
|
+
hint=install_line("serve"),
|
|
44
43
|
) from exc
|
|
45
44
|
|
|
46
45
|
|
|
@@ -160,6 +159,44 @@ def _is_directory(endpoint: Any) -> bool:
|
|
|
160
159
|
return str(getattr(endpoint, "artifact", "onnx")) == "checkpoint_dir"
|
|
161
160
|
|
|
162
161
|
|
|
162
|
+
def billing_rule(endpoint: Any) -> str:
|
|
163
|
+
"""One line: what this endpoint costs and how the hub counts it (P2)."""
|
|
164
|
+
from lucen_cli.client import unset_to_none
|
|
165
|
+
|
|
166
|
+
if not getattr(endpoint, "priced", False):
|
|
167
|
+
return "free: it runs on your own machine"
|
|
168
|
+
rate = float(getattr(endpoint, "rate_usd_per_hour", 0) or 0)
|
|
169
|
+
if unset_to_none(getattr(endpoint, "cost_basis", None)) == "sessions":
|
|
170
|
+
return f"${rate:.4f}/h per session's GPU time (created before uptime billing began)"
|
|
171
|
+
return (
|
|
172
|
+
f"${rate:.4f}/h for its uptime, from the claim until it stops, "
|
|
173
|
+
"whether or not a robot is connected"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def uptime(seconds: float) -> str:
|
|
178
|
+
"""`41 s`, `12 min`, `3.5 h`: how long an endpoint has been billed for."""
|
|
179
|
+
if seconds < 90:
|
|
180
|
+
return f"{round(seconds)} s"
|
|
181
|
+
minutes = seconds / 60
|
|
182
|
+
if minutes < 90:
|
|
183
|
+
return f"{round(minutes)} min"
|
|
184
|
+
return f"{minutes / 60:.1f} h"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cost_line(endpoint: Any) -> str:
|
|
188
|
+
"""`cost so far $0.0171 · up for 60 s` / `cost $0.0171 · up for 41 min` / `free`."""
|
|
189
|
+
from lucen_cli.client import unset_to_none
|
|
190
|
+
|
|
191
|
+
if not getattr(endpoint, "priced", False):
|
|
192
|
+
return "free"
|
|
193
|
+
cost = float(unset_to_none(getattr(endpoint, "cost_usd", None)) or 0)
|
|
194
|
+
word = "cost so far" if str(endpoint.status) == "running" else "cost"
|
|
195
|
+
seconds = unset_to_none(getattr(endpoint, "billed_seconds", None))
|
|
196
|
+
up = f" · up for {uptime(float(seconds))}" if seconds is not None else ""
|
|
197
|
+
return f"{word} ${cost:.4f}{up}"
|
|
198
|
+
|
|
199
|
+
|
|
163
200
|
def idle_rule(endpoint: Any) -> str:
|
|
164
201
|
"""One line: when the hub stops this endpoint for being idle, and the session timeout."""
|
|
165
202
|
from lucen_cli.client import unset_to_none
|
|
@@ -288,6 +325,7 @@ def create(
|
|
|
288
325
|
markup=False,
|
|
289
326
|
)
|
|
290
327
|
_print_artifact(endpoint)
|
|
328
|
+
stdout.print(f" {billing_rule(endpoint)}", markup=False)
|
|
291
329
|
stdout.print(f" {idle_rule(endpoint)}", markup=False)
|
|
292
330
|
if str(endpoint.executor) == "worker":
|
|
293
331
|
extra = "[vla,serve]" if _is_directory(endpoint) else "[serve]"
|
|
@@ -316,12 +354,22 @@ def estimate(
|
|
|
316
354
|
policy_path: Annotated[
|
|
317
355
|
str | None, typer.Option("--policy-path", help="The checkpoint directory it would serve.")
|
|
318
356
|
] = None,
|
|
357
|
+
owner: Annotated[
|
|
358
|
+
str | None, typer.Option("--owner", help="Price it for this handle (as `create` bills).")
|
|
359
|
+
] = None,
|
|
319
360
|
as_json: Annotated[bool, typer.Option("--json", help="Machine-readable output.")] = False,
|
|
320
361
|
) -> None:
|
|
321
362
|
"""Price an endpoint: the tier its size selects x hours. Creates nothing."""
|
|
322
363
|
state = ctx.ensure_object(AppState)
|
|
323
364
|
body: dict[str, Any] = {"model_repo": model, "executor": executor, "hours": hours}
|
|
324
|
-
|
|
365
|
+
# T2: without `--owner` a member of a hosted organization was priced as themself and told
|
|
366
|
+
# hosted GPUs are not open to them, while `create --owner` would have been accepted.
|
|
367
|
+
for key, value in (
|
|
368
|
+
("params", params),
|
|
369
|
+
("onnx_path", onnx_path),
|
|
370
|
+
("policy_path", policy_path),
|
|
371
|
+
("owner", owner),
|
|
372
|
+
):
|
|
325
373
|
if value is not None:
|
|
326
374
|
body[key] = value
|
|
327
375
|
with state.client(require_auth=True) as client:
|
|
@@ -337,10 +385,16 @@ def estimate(
|
|
|
337
385
|
stdout.print(
|
|
338
386
|
f"{est.executor}: tier {est.tier} ({size}{est.vram_bytes / 2**30:.1f} GiB"
|
|
339
387
|
+ (f" from {basis}" if basis else "")
|
|
340
|
-
+ f") · ${float(est.rate_usd_per_hour):.4f}/h x {float(est.hours):g} h = "
|
|
388
|
+
+ f") · ${float(est.rate_usd_per_hour):.4f}/h x {float(est.hours):g} h up = "
|
|
341
389
|
f"${float(est.cost_usd_max):.4f}" + ("" if est.priced else " (unpriced)"),
|
|
342
390
|
markup=False,
|
|
343
391
|
)
|
|
392
|
+
if est.priced:
|
|
393
|
+
stdout.print(
|
|
394
|
+
" billed for its uptime, from the claim until it stops, whether or not a robot "
|
|
395
|
+
"is connected; sessions add nothing",
|
|
396
|
+
markup=False,
|
|
397
|
+
)
|
|
344
398
|
notes = getattr(est, "notes", None)
|
|
345
399
|
for note in notes if isinstance(notes, list) else []:
|
|
346
400
|
stdout.print(f" note: {note}", markup=False)
|
|
@@ -397,6 +451,7 @@ def show(
|
|
|
397
451
|
markup=False,
|
|
398
452
|
)
|
|
399
453
|
_print_artifact(endpoint)
|
|
454
|
+
stdout.print(f" {cost_line(endpoint)} · {billing_rule(endpoint)}", markup=False)
|
|
400
455
|
stdout.print(f" {idle_rule(endpoint)}", markup=False)
|
|
401
456
|
from lucen_cli.client import unset_to_none
|
|
402
457
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""The install line for one of the CLI's extras, in one place (D1).
|
|
2
|
+
|
|
3
|
+
Every refusal that names a missing engine prints the line the docs show, which
|
|
4
|
+
since D1 is PyPI's: `uv tool install "lucen-cli[rollout]"`. Before this each
|
|
5
|
+
site carried its own copy of the checkout line (`uv tool install --from
|
|
6
|
+
"./packages/cli[rollout]" --with ./packages/sdk/py lucen-cli`), which a
|
|
7
|
+
stranger with the package from PyPI cannot run. The extra is quoted because an
|
|
8
|
+
unquoted `[…]` is a glob zsh refuses ("no matches found").
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Final
|
|
14
|
+
|
|
15
|
+
DISTRIBUTION: Final[str] = "lucen-cli"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _spec(extras: tuple[str, ...]) -> str:
|
|
19
|
+
return f'"{DISTRIBUTION}[{",".join(extras)}]"' if extras else DISTRIBUTION
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def install_line(*extras: str) -> str:
|
|
23
|
+
"""`uv tool install "lucen-cli[rl]"`: the CLI as a tool on the PATH."""
|
|
24
|
+
return f"uv tool install {_spec(extras)}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def pip_line(*extras: str) -> str:
|
|
28
|
+
"""`uv pip install "lucen-cli[rollout]"`: into the environment a script runs in."""
|
|
29
|
+
return f"uv pip install {_spec(extras)}"
|