envloop-cli 1.7.0__tar.gz → 1.12.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 (35) hide show
  1. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/PKG-INFO +26 -3
  2. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/README.pypi.md +25 -2
  3. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/agent_runs.py +7 -3
  4. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/auth_cli.py +1 -1
  5. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/cli.py +87 -32
  6. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/cli_errors.py +152 -104
  7. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/cli_output.py +15 -16
  8. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/client.py +16 -3
  9. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/http.py +78 -25
  10. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/job_logs.py +6 -2
  11. envloop_cli-1.12.0/envloop/job_spec_cli.py +138 -0
  12. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/rsi_cli.py +16 -5
  13. envloop_cli-1.12.0/envloop/terminal.py +77 -0
  14. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/train.py +9 -4
  15. envloop_cli-1.12.0/envloop/train_log_cli.py +128 -0
  16. envloop_cli-1.12.0/envloop/train_progress.py +51 -0
  17. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/pyproject.toml +1 -1
  18. envloop_cli-1.7.0/envloop/train_log_cli.py +0 -97
  19. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/.gitignore +0 -0
  20. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/__init__.py +0 -0
  21. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/auth_login.py +0 -0
  22. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/auth_workspace.py +0 -0
  23. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/config.py +0 -0
  24. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/inference.py +0 -0
  25. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/lifecycle_cli.py +0 -0
  26. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/navigation_resources.py +0 -0
  27. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/profile_secrets.py +0 -0
  28. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/profile_updates.py +0 -0
  29. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/resource_cli.py +0 -0
  30. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/resources.py +0 -0
  31. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/sandbox.py +0 -0
  32. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/train_runner_cli.py +0 -0
  33. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/training/__init__.py +0 -0
  34. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/utils/__init__.py +0 -0
  35. {envloop_cli-1.7.0 → envloop_cli-1.12.0}/envloop/utils/logging.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: envloop-cli
3
- Version: 1.7.0
3
+ Version: 1.12.0
4
4
  Summary: EnvLoop CLI and Python SDK for jobs, training, checkpoints and inference
5
5
  Project-URL: Homepage, https://envloop.ai
6
6
  Project-URL: Documentation, https://docs.envloop.ai
@@ -81,6 +81,19 @@ chmod 700 "${ENVLOOP_HOME:-$HOME/.envloop}"
81
81
 
82
82
  An insecure directory causes `el` to fail with `CONFIG_INSECURE_PATH`.
83
83
 
84
+ ## Errors
85
+
86
+ Errors go to stderr as an error code, a short explanation and the applicable
87
+ HTTP status, without a Hint. Interactive terminals use a red heading and a dim
88
+ HTTP status. Redirected output, pipes, `TERM=dumb`, or a nonempty `NO_COLOR`
89
+ disable color. `--json` stdout remains JSON.
90
+
91
+ ```text
92
+ Error: PROFILE_NOT_FOUND
93
+ The Profile was not found or is not readable in the current scope.
94
+ HTTP 404
95
+ ```
96
+
84
97
  ## Explore your project
85
98
 
86
99
  ```bash
@@ -153,13 +166,16 @@ Prepare a recipe-supported `REQUEST.json` using the [SFT quickstart](https://doc
153
166
  Training uses billable resources. Check your project permissions, balance and data format, then submit once:
154
167
 
155
168
  ```bash
156
- el job submit --type train --file REQUEST.json
169
+ el job submit --type train --spec REQUEST.json
157
170
  el job get JOB_ID
158
171
  el job log JOB_ID --follow
159
172
  el job result JOB_ID
160
173
  el checkpoint list --job-id JOB_ID
161
174
  ```
162
175
 
176
+ `job submit --spec` reads the complete Job specification JSON file for Train or Inference.
177
+ The former `job submit --file` option is removed; the JSON payload is unchanged.
178
+
163
179
  Replace `JOB_ID` with the receipt's value. Read the training status and logs, then find the registered checkpoint after success.
164
180
  Ctrl-C ends log reading; to stop training, use `el job stop JOB_ID` and check its state and resource cleanup.
165
181
  See [Checkpoints](https://docs.envloop.ai/training/checkpoints) to download or delete saved weights.
@@ -182,7 +198,7 @@ Choose a published Runner readable in your project. Save this request as `gpu-ru
182
198
  ```
183
199
 
184
200
  ```bash
185
- el job submit --type train --file gpu-run.json
201
+ el job submit --type train --spec gpu-run.json
186
202
  el job get JOB_ID
187
203
  el job log JOB_ID --follow
188
204
  el job result JOB_ID
@@ -193,3 +209,10 @@ Replace `JOB_ID` with the returned value. The example has two independent Trials
193
209
  The request file does not depend on GPU shortcut flags. If `el job submit --help` lists both `--trails` and `--gput-type`, you can instead use the shortcuts in [GPU Runner training](https://docs.envloop.ai/training/runner). Choose one submission method to avoid duplicate runs. The SDK accepts the same dictionary through `client.training_jobs.submit(request)`.
194
210
 
195
211
  The platform starts the published Revision’s `main.sh` / `main.py` using the Runner image; it does not upload local code. An optional command can only supply a declared `run_command` parameter. If this mode or GPU type is unavailable, contact your project administrator. Use `el job stop JOB_ID` to stop every Trial, then inspect each Trial’s `cleanup_confirmed` and download the uploaded artifacts you need.
212
+
213
+
214
+ CLI output uses compact timestamps and highlights statuses and log levels by default on terminals.
215
+ Use `el --no-color job list` or append `--no-color` to a command to disable highlighting, including errors.
216
+ `NO_COLOR=1`, `TERM=dumb`, pipes, and redirected output also disable colors. JSON preserves original values.
217
+ List times use the local timezone; Job logs retain the recorded timezone, with full precision and offsets available through `--json`.
218
+ `el job run live RUN --follow --json` emits complete response frames as NDJSON; Ctrl-C stops reading without stopping the Run.
@@ -68,6 +68,19 @@ chmod 700 "${ENVLOOP_HOME:-$HOME/.envloop}"
68
68
 
69
69
  An insecure directory causes `el` to fail with `CONFIG_INSECURE_PATH`.
70
70
 
71
+ ## Errors
72
+
73
+ Errors go to stderr as an error code, a short explanation and the applicable
74
+ HTTP status, without a Hint. Interactive terminals use a red heading and a dim
75
+ HTTP status. Redirected output, pipes, `TERM=dumb`, or a nonempty `NO_COLOR`
76
+ disable color. `--json` stdout remains JSON.
77
+
78
+ ```text
79
+ Error: PROFILE_NOT_FOUND
80
+ The Profile was not found or is not readable in the current scope.
81
+ HTTP 404
82
+ ```
83
+
71
84
  ## Explore your project
72
85
 
73
86
  ```bash
@@ -140,13 +153,16 @@ Prepare a recipe-supported `REQUEST.json` using the [SFT quickstart](https://doc
140
153
  Training uses billable resources. Check your project permissions, balance and data format, then submit once:
141
154
 
142
155
  ```bash
143
- el job submit --type train --file REQUEST.json
156
+ el job submit --type train --spec REQUEST.json
144
157
  el job get JOB_ID
145
158
  el job log JOB_ID --follow
146
159
  el job result JOB_ID
147
160
  el checkpoint list --job-id JOB_ID
148
161
  ```
149
162
 
163
+ `job submit --spec` reads the complete Job specification JSON file for Train or Inference.
164
+ The former `job submit --file` option is removed; the JSON payload is unchanged.
165
+
150
166
  Replace `JOB_ID` with the receipt's value. Read the training status and logs, then find the registered checkpoint after success.
151
167
  Ctrl-C ends log reading; to stop training, use `el job stop JOB_ID` and check its state and resource cleanup.
152
168
  See [Checkpoints](https://docs.envloop.ai/training/checkpoints) to download or delete saved weights.
@@ -169,7 +185,7 @@ Choose a published Runner readable in your project. Save this request as `gpu-ru
169
185
  ```
170
186
 
171
187
  ```bash
172
- el job submit --type train --file gpu-run.json
188
+ el job submit --type train --spec gpu-run.json
173
189
  el job get JOB_ID
174
190
  el job log JOB_ID --follow
175
191
  el job result JOB_ID
@@ -180,3 +196,10 @@ Replace `JOB_ID` with the returned value. The example has two independent Trials
180
196
  The request file does not depend on GPU shortcut flags. If `el job submit --help` lists both `--trails` and `--gput-type`, you can instead use the shortcuts in [GPU Runner training](https://docs.envloop.ai/training/runner). Choose one submission method to avoid duplicate runs. The SDK accepts the same dictionary through `client.training_jobs.submit(request)`.
181
197
 
182
198
  The platform starts the published Revision’s `main.sh` / `main.py` using the Runner image; it does not upload local code. An optional command can only supply a declared `run_command` parameter. If this mode or GPU type is unavailable, contact your project administrator. Use `el job stop JOB_ID` to stop every Trial, then inspect each Trial’s `cleanup_confirmed` and download the uploaded artifacts you need.
199
+
200
+
201
+ CLI output uses compact timestamps and highlights statuses and log levels by default on terminals.
202
+ Use `el --no-color job list` or append `--no-color` to a command to disable highlighting, including errors.
203
+ `NO_COLOR=1`, `TERM=dumb`, pipes, and redirected output also disable colors. JSON preserves original values.
204
+ List times use the local timezone; Job logs retain the recorded timezone, with full precision and offsets available through `--json`.
205
+ `el job run live RUN --follow --json` emits complete response frames as NDJSON; Ctrl-C stops reading without stopping the Run.
@@ -1,7 +1,7 @@
1
1
  """Agent runs: a read-only view of Train Jobs grouped by their agent's run label.
2
2
 
3
3
  The platform stores no run object. Agents tag every Train Job they create with
4
- ``user_metadata`` (the RSI loop: ``rsi_run``, ``rsi_round``, ``rsi_checkpoint``...);
4
+ ``tags`` (the RSI loop: ``rsi_run``, ``rsi_round``, ``rsi_checkpoint``...);
5
5
  this module pages through ``GET /v1/job`` and derives runs from those tags,
6
6
  using the same rules as the Console's agent runs page.
7
7
  """
@@ -34,7 +34,11 @@ class AgentRunError(RuntimeError):
34
34
 
35
35
 
36
36
  def _metadata(job) -> dict:
37
- metadata = job.get("user_metadata")
37
+ metadata = job.get("tags")
38
+ if not isinstance(metadata, dict):
39
+ metadata = job.get("labels")
40
+ if not isinstance(metadata, dict):
41
+ metadata = job.get("user_metadata")
38
42
  return metadata if isinstance(metadata, dict) else {}
39
43
 
40
44
 
@@ -111,7 +115,7 @@ def _job_view(job) -> dict:
111
115
  "lora_rank": job.get("lora_rank"),
112
116
  "model_id": job.get("model_id"),
113
117
  "checkpoint": _checkpoint(job),
114
- "user_metadata": metadata,
118
+ "tags": metadata,
115
119
  }
116
120
 
117
121
 
@@ -282,7 +282,7 @@ def run_auth(args, parser):
282
282
  report["check"] = {401: "authentication_rejected", 403: "authorization_denied"}.get(
283
283
  getattr(error, "status_code", None), safe_error_code(error))
284
284
  _print(report, args.json)
285
- print(format_error(error, args=args, program=parser.prog), file=sys.stderr)
285
+ print(format_error(error), file=sys.stderr)
286
286
  return 1
287
287
  _print(report, getattr(args, "json", False))
288
288
  return 0
@@ -39,6 +39,8 @@ class HelpArgumentParser(argparse.ArgumentParser):
39
39
  def __init__(self, *args, **kwargs):
40
40
  kwargs.setdefault('formatter_class', CommandHelpFormatter)
41
41
  super().__init__(*args, **kwargs)
42
+ self.add_argument("--no-color", action="store_true", default=argparse.SUPPRESS,
43
+ help="Disable terminal highlighting")
42
44
  self.set_defaults(_help_parser=self)
43
45
  self._help_parser = self
44
46
 
@@ -61,9 +63,21 @@ class HelpArgumentParser(argparse.ArgumentParser):
61
63
  def error(self, message):
62
64
  if message.startswith("unrecognized arguments:"):
63
65
  message = "unrecognized arguments"
66
+ # argparse includes raw argument values in these diagnostics. Keep the
67
+ # option/type description while excluding values (which can be secrets).
68
+ if "invalid choice:" in message:
69
+ message = message.split("invalid choice:", 1)[0] + "invalid choice"
70
+ elif " value:" in message:
71
+ message = message.split(" value:", 1)[0] + " value"
72
+ elif message.startswith("ambiguous option:"):
73
+ message = "ambiguous option"
74
+ from .cli_errors import error_block
75
+
64
76
  command = self._help_parser
77
+ print(error_block("INVALID_ARGUMENT", message), file=sys.stderr)
78
+ print(file=sys.stderr)
65
79
  command.print_help(sys.stderr)
66
- command.exit(2, f"\n{command.prog}: error: {message}\n")
80
+ command.exit(2)
67
81
 
68
82
 
69
83
  class CommandHelpFormatter(argparse.HelpFormatter):
@@ -133,10 +147,10 @@ def log_page_size(value):
133
147
 
134
148
  def runner_arguments(submit):
135
149
  submit.add_argument("--runner", metavar="NAME[@REVISION]",
136
- help="Agent/Train: namespace/name@REVISION; Sandbox: registered Runner name (for example base)")
137
- submit.add_argument("--job-id", help="caller-selected Job ID")
138
- submit.add_argument("--tag", help="run label")
139
- submit.add_argument("--priority", type=int, help="waiting priority; larger values run first (default: 0)")
150
+ help="Agent/Train/Inference: namespace/name@REVISION; Sandbox: registered Runner name (for example base)")
151
+ submit.add_argument("--job-id", help="Agent/Sandbox: caller-selected Job ID")
152
+ submit.add_argument("--tag", help="Agent/Sandbox: run label")
153
+ submit.add_argument("--priority", type=int, help="Agent/Sandbox/Train recipe: waiting priority; explicit value overrides spec (server default: 0)")
140
154
  submit.add_argument("--command", help="Agent/Sandbox: shell override; Train: declared run_command parameter passed to the published Runner entrypoint; preserves multiline text")
141
155
 
142
156
 
@@ -147,14 +161,14 @@ def parser():
147
161
  description="Manage Agent, Sandbox, Train and Inference Jobs and worker trials with EnvLoop.\nConfigure your connection with 'auth config'.",
148
162
  )
149
163
  cli._optionals.title = "Options"
150
- cli.set_defaults(api_url=None, workspace_id=None)
164
+ cli.set_defaults(api_url=None, workspace_id=None, no_color=False)
151
165
  cli.add_argument("-V", "--version", action="version", version=f"%(prog)s {CLI_VERSION}", help="Display CLI version")
152
166
  groups = cli.add_subparsers(dest="group", title="Commands", prog=cli.prog, required=True)
153
167
  from .auth_cli import add_auth_commands
154
168
 
155
169
  jobs = groups.add_parser("job", help="Submit and manage Agent, Sandbox, Train and Inference Jobs").add_subparsers(dest="action", required=True)
156
170
  job_submit = jobs.add_parser("submit", help="Submit an Agent, Sandbox, Train or Inference Job", allow_abbrev=False)
157
- job_submit.add_argument("--type", choices=("agent", "sandbox", "train", "inference"), default="agent", help="Job type (default: agent)")
171
+ job_submit.add_argument("--type", choices=("agent", "sandbox", "train", "inference"), default=None, help="Job type (default: spec type, otherwise agent)")
158
172
  job_submit.add_argument("--image", metavar="IMAGE", help="Sandbox image override (server default: python:3.11); Train Runner: public spelling of --docker")
159
173
  from .train_runner_cli import memory_mebibytes, positive_integer
160
174
 
@@ -166,7 +180,7 @@ def parser():
166
180
  for option in ('memory', 'disk', 'shm-size'):
167
181
  job_submit.add_argument('--' + option, type=memory_mebibytes, metavar="SIZE",
168
182
  help="Train Runner: per-Trial size in Mi/Gi/Ti (for example 64Gi)")
169
- job_submit.add_argument("--file", type=Path, help="Managed Train or Inference Job JSON (spec or Train trials array; interactive training is unsupported)")
183
+ job_submit.add_argument("--spec", type=Path, metavar="REQUEST.json", help="Job specification JSON; explicit submission options override file values (except --trails)")
170
184
  job_submit.add_argument("--source", type=Path, help="two-file directory for a single-task Job")
171
185
  job_submit.add_argument("--task-id", help="select one task within the dataset")
172
186
  job_submit.add_argument(
@@ -175,17 +189,17 @@ def parser():
175
189
  )
176
190
  job_submit.add_argument("--dataset", dest="dataset_option", type=dataset_reference, metavar="NAME[@VERSION]",
177
191
  help="dataset name; default selection uses the platform's pinned snapshot")
178
- job_submit.add_argument("--config", help="dataset subset; uses its declared default when omitted")
192
+ job_submit.add_argument("--config", help="Agent: dataset subset; Train recipe: named preset; omitted preserves spec or server default")
179
193
  job_submit.add_argument("--split", help="one complete split name; omitted selects all splits")
180
194
  job_submit.add_argument("--revision", help="Git branch/tag/commit; must match the platform's pinned snapshot")
181
195
  runner_arguments(job_submit)
182
196
  job_submit.add_argument("--attempts", type=int)
183
- job_submit.add_argument("--request-id")
197
+ job_submit.add_argument("--request-id", help="idempotency key; overrides request_id in --spec")
184
198
  job_submit.add_argument("--profile", type=profile_reference, metavar="NAME[@VERSION]",
185
- help="named runtime Profile; omitted version requires an explicit default")
199
+ help="named runtime Profile; omitted version prefers default, then newest unarchived creation")
186
200
  job_submit.add_argument("--seed", type=int)
187
201
  job_submit.add_argument("--timeout-sec", type=wait_seconds,
188
- help="Agent/Sandbox or Train Runner execution timeout (whole seconds); Train --file wait timeout (default: 600)")
202
+ help="Agent/Sandbox or Train Runner execution timeout (whole seconds); Train recipe --wait timeout (default: 600)")
189
203
  job_submit.add_argument("--delete-delay", type=nonnegative_seconds, metavar="SECONDS",
190
204
  help="keep each sandbox (E2B or Fly) after completion for SECONDS (default: 0); cancellation/timeout delete immediately")
191
205
  job_submit.add_argument("--wait", action="store_true")
@@ -207,6 +221,8 @@ def parser():
207
221
  train_log = jobs.add_parser("log", help="Read or follow Agent, Sandbox or Train Job logs")
208
222
  train_log.add_argument("job_id")
209
223
  train_log.add_argument("--source", choices=("all", "system", "runtime"), default="all")
224
+ train_log.add_argument("--process", metavar="NAME",
225
+ help="Only one recorded process, e.g. trainer (a Train Job's step metrics)")
210
226
  position = train_log.add_mutually_exclusive_group()
211
227
  position.add_argument("--tail", type=log_page_size, help="Start with the last N records (default: 200)")
212
228
  position.add_argument("--cursor", help="Resume from a previously returned log cursor")
@@ -217,7 +233,7 @@ def parser():
217
233
  runs_list = runs.add_parser("list", help="List agent runs, most recently updated first")
218
234
  runs_list.add_argument("--json", action="store_true", help="Output the complete response as JSON (default: table)")
219
235
  runs_get = runs.add_parser("get", help="Show a run's rounds, Train Jobs and checkpoints")
220
- runs_get.add_argument("run", metavar="RUN", help="run label: user_metadata.rsi_run or agent_run")
236
+ runs_get.add_argument("run", metavar="RUN", help="run tag: tags.rsi_run or agent_run")
221
237
  runs_get.add_argument("--json", action="store_true", help="Output the complete run as JSON (default: tables)")
222
238
  runs_start = runs.add_parser("start", help="Start a new RSI loop run (paid; needs training.run)")
223
239
  runs_start.add_argument("run", metavar="RUN", help="new run id: lower-case letters, digits and dashes")
@@ -233,6 +249,7 @@ def parser():
233
249
  runs_start.add_argument("--student-model", help="gateway student model of a dry run")
234
250
  runs_live = runs.add_parser("live", help="Show what a run does now; --follow streams new events")
235
251
  runs_live.add_argument("run", metavar="RUN")
252
+ runs_live.add_argument("--json", action="store_true", help="Output JSON; --follow emits complete frames as NDJSON")
236
253
  runs_live.add_argument("--since", type=int, help="only events after this cursor (the previous next_cursor)")
237
254
  runs_live.add_argument("--follow", action="store_true", help="poll and print new events until the run ends; Ctrl-C exits without stopping it")
238
255
  runs_steer = runs.add_parser("steer", help="Message the running operator now (needs training.run)")
@@ -273,7 +290,7 @@ def parser():
273
290
  update = profiles.add_parser("update", help="Edit an existing Profile by publishing a new immutable version")
274
291
  update.add_argument("name")
275
292
  update.add_argument("version", help="New version; existing versions cannot be overwritten")
276
- update.add_argument("--from-version", help="Source version; omit to use the explicit default")
293
+ update.add_argument("--from-version", help="Source version; omit to use default, then newest unarchived creation")
277
294
  update.add_argument("--file", type=Path, required=True, help="TOML or JSON patch: merge params keys, replace supplied metadata; preserve omitted fields")
278
295
  update.add_argument("--runner", metavar="RUNNER", help="Bind a Runner revision; v1 requires a full commit; omit to preserve the source binding")
279
296
  update_secret = update.add_mutually_exclusive_group()
@@ -378,11 +395,24 @@ def resolve_cli_connection(api_url=None, workspace_id=None, project_id=None):
378
395
 
379
396
 
380
397
  def main(argv=None):
398
+ from .terminal import output_options
399
+
400
+ with output_options():
401
+ return _main(argv)
402
+
403
+
404
+ def _main(argv=None):
381
405
  cli = parser()
382
406
  argv = sys.argv[1:] if argv is None else argv
383
407
  if not argv:
384
408
  cli.print_help()
385
409
  return 0
410
+ # Apply display options before validation, even if an earlier argument is invalid.
411
+ options = argv[:argv.index("--")] if "--" in argv else argv
412
+ if "--no-color" in options:
413
+ from .terminal import disable_color
414
+
415
+ disable_color()
386
416
  if any(value == "--api-key" or value.startswith("--api-key=") for value in argv):
387
417
  cli.error("API keys must be entered with auth config or supplied through ENVLOOP_API_KEY")
388
418
  args = cli.parse_args(argv)
@@ -397,22 +427,38 @@ def main(argv=None):
397
427
  from .cli_errors import format_error
398
428
  from .config import ConfigError
399
429
 
400
- print(format_error(ConfigError('AUTH_INPUT_CANCELLED'), args=args, program=cli.prog), file=sys.stderr)
430
+ print(format_error(ConfigError('AUTH_INPUT_CANCELLED')), file=sys.stderr)
401
431
  return 1
402
432
  except Exception as exc:
403
433
  from .cli_errors import format_error
404
434
 
405
- print(format_error(exc, args=args, program=cli.prog), file=sys.stderr)
435
+ print(format_error(exc), file=sys.stderr)
406
436
  return 1
407
437
  if args.group == "job" and args.action == "submit":
408
438
  from .train_runner_cli import SHORTCUT_FIELDS, runner_request
409
439
 
410
- runner_mode = args.type == 'train' and args.file is None
411
- if not runner_mode and any(getattr(args, key) is not None for key in SHORTCUT_FIELDS):
412
- cli.error('Train Runner shortcuts require --type train --runner and cannot be combined with --file')
413
- if args.type in {"train", "inference"}:
414
- if args.file is None and not runner_mode:
415
- cli.error(f"--type {args.type} requires --file REQUEST.json")
440
+ if args.spec is not None:
441
+ from .job_spec_cli import SpecFileError, spec_request
442
+
443
+ try:
444
+ args.job_request = spec_request(args)
445
+ except (OSError, UnicodeError, json.JSONDecodeError, SpecFileError) as exc:
446
+ from .cli_errors import format_error
447
+
448
+ print(format_error(exc), file=sys.stderr)
449
+ return 1
450
+ except (ValueError, TypeError) as exc:
451
+ cli.error(str(exc) if isinstance(exc, ValueError) else 'Unable to merge Job spec')
452
+ else:
453
+ args.type = args.type or 'agent'
454
+ runner_mode = args.type == 'train' and args.spec is None
455
+ if args.spec is None and not runner_mode and any(getattr(args, key) is not None for key in SHORTCUT_FIELDS):
456
+ cli.error('Train Runner shortcuts require --type train --runner and cannot be combined with --spec')
457
+ if args.spec is not None:
458
+ pass # The merged request was validated above; the platform owns final admission.
459
+ elif args.type in {"train", "inference"}:
460
+ if args.spec is None and not runner_mode:
461
+ cli.error(f"--type {args.type} requires --spec REQUEST.json")
416
462
  if args.type == 'inference' and (args.wait or args.timeout_sec is not None):
417
463
  cli.error('Inference is a continuous service; --wait and --timeout-sec do not apply')
418
464
  agent_fields = ("dataset", "dataset_option", "source", "task_id", "config", "split", "revision",
@@ -420,7 +466,7 @@ def main(argv=None):
420
466
  if not runner_mode:
421
467
  agent_fields += ('runner', 'command', 'request_id', 'image')
422
468
  if any(getattr(args, key) is not None for key in agent_fields):
423
- cli.error(f"--type {args.type} cannot be combined with Agent submission options or mixed --file/Runner input")
469
+ cli.error(f"--type {args.type} cannot be combined with Agent submission options or mixed --spec/Runner input")
424
470
  if runner_mode:
425
471
  try:
426
472
  args.train_request = runner_request(args)
@@ -430,8 +476,8 @@ def main(argv=None):
430
476
  from .sandbox import sandbox_runner_selector, validate_image
431
477
 
432
478
  if any(getattr(args, key) is not None for key in (
433
- 'file', 'dataset', 'dataset_option', 'source', 'task_id', 'config', 'split', 'revision', 'seed')):
434
- cli.error('--type sandbox accepts --runner without Dataset, Task, Train file or seed options')
479
+ 'spec', 'dataset', 'dataset_option', 'source', 'task_id', 'config', 'split', 'revision', 'seed')):
480
+ cli.error('--type sandbox accepts --runner without Dataset, Task, Job spec or seed options')
435
481
  try:
436
482
  args.runner = sandbox_runner_selector(args.runner)
437
483
  if args.image is not None:
@@ -443,8 +489,8 @@ def main(argv=None):
443
489
  cli.error('Sandbox --timeout-sec must be whole seconds')
444
490
  args.timeout_sec = int(args.timeout_sec)
445
491
  else:
446
- if args.file is not None:
447
- cli.error("--file requires --type train or inference")
492
+ if args.spec is not None:
493
+ cli.error("--spec requires --type train or inference")
448
494
  if args.image is not None:
449
495
  cli.error('--image requires --type sandbox')
450
496
  if args.runner is not None:
@@ -526,17 +572,19 @@ def main(argv=None):
526
572
  elif args.group == "job":
527
573
  if args.action == "submit":
528
574
  if args.type == 'inference':
529
- result = client.jobs.submit_inference(json.loads(args.file.read_text(encoding='utf-8')))
575
+ result = client.jobs.submit_inference(args.job_request)
530
576
  elif args.type == "train":
531
- request = args.train_request if args.file is None else json.loads(args.file.read_text(encoding="utf-8"))
577
+ request = args.train_request if args.spec is None else args.job_request
532
578
  result = client.training_jobs.submit(request)
579
+ elif args.spec is not None:
580
+ result = client.jobs.submit_spec(args.job_request)
533
581
  else:
534
582
  result = client.jobs.submit(args.dataset, type=args.type, source=args.source, task_id=args.task_id,
535
583
  attempts=args.attempts if args.attempts is not None else 1,
536
584
  request_id=args.request_id, config=args.config, split=args.split,
537
585
  revision=args.revision, **submission_options(args))
538
586
  if args.wait:
539
- wait_options = {"timeout": args.timeout_sec} if args.type == "train" and args.file is not None and args.timeout_sec is not None else {}
587
+ wait_options = {"timeout": args.timeout_sec} if args.type == "train" and args.spec is not None and args.timeout_sec is not None else {}
540
588
  result = client.jobs.wait(result["job_id"], **wait_options)
541
589
  elif args.action == 'event':
542
590
  result = client.jobs.events(args.job_id)
@@ -563,6 +611,13 @@ def main(argv=None):
563
611
  result = client.jobs.list(**options)
564
612
  else:
565
613
  result = getattr(client.jobs, 'status' if args.action == 'get' else args.action)(args.job_id)
614
+ if args.action == 'get':
615
+ from .train_progress import job_progress, progress_line
616
+
617
+ # The JSON on stdout is unchanged; a Train Job's live progress is a readable stderr line.
618
+ line = progress_line(job_progress(result))
619
+ if line:
620
+ print(line, file=sys.stderr)
566
621
  elif args.group == "trail":
567
622
  if args.action == "artifact":
568
623
  if args.artifact_action == "download":
@@ -606,10 +661,10 @@ def main(argv=None):
606
661
 
607
662
  if isinstance(exc, CleanupError):
608
663
  print(json.dumps(operation_output(exc.operation), ensure_ascii=False))
609
- print(format_error(exc, args=args, program=cli.prog), file=sys.stderr)
664
+ print(format_error(exc), file=sys.stderr)
610
665
  return 1
611
666
  # Do not emit arbitrary exception messages, file content or credentials.
612
- print(format_error(exc, args=args, program=cli.prog), file=sys.stderr)
667
+ print(format_error(exc), file=sys.stderr)
613
668
  return 1
614
669
 
615
670