veris-cli 2.27.1__tar.gz → 2.29.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 (34) hide show
  1. {veris_cli-2.27.1 → veris_cli-2.29.0}/PKG-INFO +1 -1
  2. {veris_cli-2.27.1 → veris_cli-2.29.0}/pyproject.toml +1 -1
  3. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/api.py +30 -14
  4. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/commands/env.py +192 -10
  5. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/commands/scenarios.py +107 -24
  6. {veris_cli-2.27.1 → veris_cli-2.29.0}/.gitignore +0 -0
  7. {veris_cli-2.27.1 → veris_cli-2.29.0}/README.md +0 -0
  8. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/__init__.py +0 -0
  9. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/build_context.py +0 -0
  10. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/cli.py +0 -0
  11. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/commands/__init__.py +0 -0
  12. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/commands/_helpers.py +0 -0
  13. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/commands/auth.py +0 -0
  14. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/commands/evaluations.py +0 -0
  15. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/commands/profile.py +0 -0
  16. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/commands/reports.py +0 -0
  17. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/commands/run.py +0 -0
  18. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/commands/simulations.py +0 -0
  19. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/config.py +0 -0
  20. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/output.py +0 -0
  21. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/prompts.py +0 -0
  22. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/run_output.py +0 -0
  23. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/scripts/__init__.py +0 -0
  24. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/scripts/docker_build.sh +0 -0
  25. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/scripts/docker_push.sh +0 -0
  26. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/searchable_checkbox.py +0 -0
  27. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/stacks/__init__.py +0 -0
  28. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/stacks/nemoclaw/__init__.py +0 -0
  29. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/stacks/nemoclaw/fetch.py +0 -0
  30. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/stacks/nemoclaw/repo_shape.py +0 -0
  31. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/stacks/nemoclaw/scripts/snapshot.sh +0 -0
  32. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/stacks/nemoclaw/snapshot.py +0 -0
  33. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/templates.py +0 -0
  34. {veris_cli-2.27.1 → veris_cli-2.29.0}/src/veris_cli/veris_yaml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: veris-cli
3
- Version: 2.27.1
3
+ Version: 2.29.0
4
4
  Summary: CLI to connect local agents to the Veris backend
5
5
  Project-URL: Homepage, https://github.com/veris-ai/veris-cli
6
6
  Project-URL: Bug Tracker, https://github.com/veris-ai/veris-cli/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "veris-cli"
3
- version = "2.27.1"
3
+ version = "2.29.0"
4
4
  description = "CLI to connect local agents to the Veris backend"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -252,7 +252,7 @@ class VerisAPI:
252
252
  return response.json()
253
253
 
254
254
  def create_image_tag(self, environment_id: str, tag: str) -> dict[str, Any]:
255
- """Register an image tag. Returns push config including pull_credentials if external registry."""
255
+ """Register an image tag. Returns push config including build_credentials if external registry."""
256
256
  with httpx.Client(
257
257
  base_url=self.base_url, headers=self._headers(), timeout=self.DEFAULT_TIMEOUT
258
258
  ) as client:
@@ -263,6 +263,20 @@ class VerisAPI:
263
263
  _raise_for_status(response)
264
264
  return response.json()
265
265
 
266
+ def download_service_wheels(self, environment_id: str) -> bytes:
267
+ """Download the exact entitled service wheels for a local (BYOC) build.
268
+
269
+ Returns the raw bytes of a zip containing services.lock.json plus
270
+ wheels/*.whl. The backend fetches the wheels with its own registry
271
+ credential, so none is exposed to the client.
272
+ """
273
+ with httpx.Client(
274
+ base_url=self.base_url, headers=self._headers(), timeout=self.DEFAULT_TIMEOUT
275
+ ) as client:
276
+ response = client.get(f"/v1/environments/{environment_id}/service-wheels")
277
+ _raise_for_status(response)
278
+ return response.content
279
+
266
280
  # Builds (Cloud Build)
267
281
  def create_build(self, environment_id: str, tag: str = "latest") -> dict[str, Any]:
268
282
  """Create a build and get a signed upload URL."""
@@ -457,21 +471,23 @@ class VerisAPI:
457
471
  return response.json()
458
472
 
459
473
  # Scenario Generation
460
- def generate_scenario_set(
461
- self,
462
- environment_id: str,
463
- num_scenarios: int = 5,
464
- image_tag: Optional[str] = None,
474
+ def create_scenario_set(self, title: str, environment_id: str) -> dict[str, Any]:
475
+ """Create an empty scenario set. Org scope is inherited from the environment."""
476
+ payload: dict[str, Any] = {"title": title, "environment_id": environment_id}
477
+ with httpx.Client(
478
+ base_url=self.base_url, headers=self._headers(), timeout=self.DEFAULT_TIMEOUT
479
+ ) as client:
480
+ response = client.post("/v1/scenario-sets", json=payload)
481
+ _raise_for_status(response)
482
+ return response.json()
483
+
484
+ def create_scenario_source(
485
+ self, set_id: str, kind: str, config: dict[str, Any]
465
486
  ) -> dict[str, Any]:
466
- """Trigger async scenario + grader generation via K8s job."""
467
- payload: dict[str, Any] = {
468
- "environment_id": environment_id,
469
- "num_scenarios": num_scenarios,
470
- }
471
- if image_tag:
472
- payload["image_tag"] = image_tag
487
+ """Create a generation source under a set and launch its Phase-1 K8s job."""
488
+ payload: dict[str, Any] = {"kind": kind, "config": config}
473
489
  with httpx.Client(base_url=self.base_url, headers=self._headers(), timeout=120) as client:
474
- response = client.post("/v1/scenario-sets/generate", json=payload)
490
+ response = client.post(f"/v1/scenario-sets/{set_id}/sources", json=payload)
475
491
  _raise_for_status(response)
476
492
  return response.json()
477
493
 
@@ -82,6 +82,89 @@ def _check_sync_guard(
82
82
  return action
83
83
 
84
84
 
85
+ def _install_services_wrap(env_id: str, image_uri: str, api: VerisAPI):
86
+ """Install the org's entitled packaged services into a just-built image.
87
+
88
+ Local counterpart of the Cloud Build install-wrap (see veris-sandbox
89
+ backend/app/services/cloud_build.py), but credential-free: the backend
90
+ serves exactly the entitled wheels from GET /service-wheels (fetched with
91
+ Veris's own registry credential), so the customer never touches the wheel
92
+ repo. We extract the served wheels into the build context, bind-mount them
93
+ plus the sealed services.lock.json, and run /usr/local/bin/install_services.sh
94
+ (baked into the Veris base image, so it's present in the FROM-derived image),
95
+ which installs offline with --find-links and asserts the entitled set.
96
+ """
97
+ import io
98
+ import os
99
+ import shutil
100
+ import subprocess
101
+ import tempfile
102
+ import zipfile
103
+
104
+ output.print_info("Fetching entitled service packages...")
105
+ try:
106
+ zip_bytes = api.download_service_wheels(env_id)
107
+ except APIError as e:
108
+ output.print_error(f"Failed to fetch service packages: {e.detail}")
109
+ sys.exit(1)
110
+
111
+ # The wrap bind-mounts the served wheels dir + the sealed lockfile (both
112
+ # shipped in the served zip — services.lock.json is authoritative from the
113
+ # backend). install_services.sh installs from --find-links with --no-index.
114
+ run_line = (
115
+ "RUN --mount=type=bind,source=service-wheels,target=/tmp/service-wheels "
116
+ "--mount=type=bind,source=services.lock.json,target=/tmp/services.lock.json "
117
+ "/usr/local/bin/install_services.sh"
118
+ )
119
+
120
+ ctx_dir = tempfile.mkdtemp(prefix="veris-wrap-")
121
+ try:
122
+ # Unpack the served zip into the build context: services.lock.json at the
123
+ # root and wheels/*.whl -> service-wheels/. ZipFile.extract sanitizes
124
+ # member paths (strips leading slashes / .. ) so a malformed entry can't
125
+ # escape ctx_dir.
126
+ wheels_dir = os.path.join(ctx_dir, "service-wheels")
127
+ os.makedirs(wheels_dir, exist_ok=True)
128
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
129
+ for member in zf.namelist():
130
+ if member.endswith("/"):
131
+ continue
132
+ name = os.path.basename(member)
133
+ if member == "services.lock.json":
134
+ with open(os.path.join(ctx_dir, "services.lock.json"), "wb") as f:
135
+ f.write(zf.read(member))
136
+ elif member.startswith("wheels/") and name.endswith(".whl"):
137
+ with open(os.path.join(wheels_dir, name), "wb") as f:
138
+ f.write(zf.read(member))
139
+
140
+ output.print_info("Installing entitled service packages...")
141
+ wrap_dockerfile = os.path.join(ctx_dir, ".services-install.Dockerfile")
142
+ with open(wrap_dockerfile, "w") as f:
143
+ f.write(f"FROM {image_uri}\n{run_line}\n")
144
+
145
+ proc = subprocess.run(
146
+ [
147
+ "docker",
148
+ "build",
149
+ "--platform",
150
+ "linux/amd64",
151
+ "-t",
152
+ image_uri,
153
+ "-f",
154
+ wrap_dockerfile,
155
+ ctx_dir,
156
+ ],
157
+ env={**os.environ, "DOCKER_BUILDKIT": "1"},
158
+ text=True,
159
+ )
160
+ if proc.returncode != 0:
161
+ output.print_error("Service package install failed")
162
+ sys.exit(1)
163
+ output.print_success("Service packages installed")
164
+ finally:
165
+ shutil.rmtree(ctx_dir, ignore_errors=True)
166
+
167
+
85
168
  def _env_push_local(
86
169
  env_id: str,
87
170
  tag: str,
@@ -92,10 +175,12 @@ def _env_push_local(
92
175
  """Build locally and push to the customer's external registry."""
93
176
  import subprocess
94
177
 
95
- pull_creds = tag_result["pull_credentials"]
178
+ build_creds = tag_result.get("build_credentials") or {}
179
+ pull_creds = build_creds["pull_credentials"]
96
180
  image_uri = tag_result["image_uri"]
97
181
  base_image = tag_result["base_image"]
98
182
  registry_host = pull_creds["registry_url"]
183
+ api = VerisAPI(profile=profile)
99
184
 
100
185
  try:
101
186
  # Step 1: Login to Veris AR to pull base image
@@ -137,6 +222,15 @@ def _env_push_local(
137
222
  sys.exit(1)
138
223
  output.print_success("Image built")
139
224
 
225
+ # Step 2.5: install the org's entitled packaged services. The base image
226
+ # ships zero packaged services, so this wrap is what actually puts them
227
+ # in the image — identical contents to the Cloud Build path. Skipped
228
+ # when the sealed manifest has no pip-installable services. The wheels
229
+ # are fetched from the backend (credential-free), not a registry token.
230
+ services_lock = build_creds.get("services_lock") or []
231
+ if any(e.get("source") == "package" for e in services_lock):
232
+ _install_services_wrap(env_id, image_uri, api)
233
+
140
234
  # Step 3: Push to customer's registry
141
235
  # Customer must already be authenticated to their own registry (e.g. aws ecr get-login-password)
142
236
  output.print_info(f"Pushing to {image_uri}...")
@@ -388,6 +482,59 @@ def _try_load_veris_yaml(path: Path) -> VerisYaml | None:
388
482
  return _maybe_migrate_shape_a(path, parsed)
389
483
 
390
484
 
485
+ def _offer_existing_unconfigured_target(
486
+ profile: str | None,
487
+ ) -> tuple[str, str | None, str | None]:
488
+ """Interactive offer to bind a backend env to an existing veris.yaml target.
489
+
490
+ Restores the v2.26.0 entry point that the managed-only onboarding rewrite
491
+ (#161) dropped: if .veris/veris.yaml defines target(s) that aren't yet bound
492
+ to a backend env on this profile, let the user pick one rather than forcing
493
+ net-new onboarding.
494
+
495
+ Returns a ``(action, target_name, agent_name)`` tuple:
496
+ - ``("pick", <target>, <agent name or None>)`` — user chose an existing target.
497
+ - ``("cancel", None, None)`` — user aborted the prompt; caller should return.
498
+ - ``("new", None, None)`` — no offer applies, or user chose "create new";
499
+ caller should fall through to net-new onboarding.
500
+
501
+ Assumes the caller already gated on an interactive TTY.
502
+ """
503
+ veris_yaml_path = Path.cwd() / ".veris" / "veris.yaml"
504
+ if not veris_yaml_path.exists():
505
+ return ("new", None, None)
506
+ parsed = _try_load_veris_yaml(veris_yaml_path)
507
+ if not (parsed and parsed.targets):
508
+ return ("new", None, None)
509
+
510
+ unconfigured = [
511
+ t
512
+ for t in parsed.target_names
513
+ if not ProjectConfig(profile=profile, target=t).get_environment_id()
514
+ ]
515
+ if not unconfigured:
516
+ return ("new", None, None)
517
+
518
+ import questionary
519
+
520
+ _NEW = "__new__"
521
+ choices = [questionary.Choice(title=t, value=t) for t in unconfigured] + [
522
+ questionary.Choice(title="Create a new environment instead", value=_NEW)
523
+ ]
524
+ selected = questionary.select(
525
+ "Existing targets found in .veris/veris.yaml. Create an environment for:",
526
+ choices=choices,
527
+ ).ask()
528
+ if selected is None:
529
+ return ("cancel", None, None)
530
+ if selected == _NEW:
531
+ return ("new", None, None)
532
+
533
+ target_obj = parsed.get_target(selected)
534
+ agent_name = target_obj.agent.name if (target_obj.agent and target_obj.agent.name) else None
535
+ return ("pick", selected, agent_name)
536
+
537
+
391
538
  def _print_recipe_upgrade_advisory(api: VerisAPI, veris_dir: Path) -> None:
392
539
  """Compare pinned recipe version in .veris/config.yaml vs /recipes/<stack>/latest.
393
540
 
@@ -690,6 +837,26 @@ def env_create(
690
837
  output.print_error("--self-serve and --stack are mutually exclusive.")
691
838
  sys.exit(1)
692
839
 
840
+ # Restore the v2.26.0 behavior (regressed by the managed-only onboarding
841
+ # rewrite in #161): when the repo already has a .veris/veris.yaml whose
842
+ # target(s) aren't yet bound to a backend env on this profile, offer to
843
+ # create an env for one of those instead of treating this as net-new
844
+ # onboarding. Picking an existing target binds a backend env to the user's
845
+ # hand-authored config (a self-serve operation), pre-filling name + agent
846
+ # name from the target block. Only fires interactively with no explicit
847
+ # --name / --stack / --self-serve, so scripted and stack flows are unchanged.
848
+ from_existing_target = False
849
+ if not name and not stack and not self_serve and sys.stdin.isatty():
850
+ action, picked_name, picked_agent_name = _offer_existing_unconfigured_target(profile)
851
+ if action == "cancel":
852
+ return
853
+ if action == "pick":
854
+ name = picked_name
855
+ self_serve = True
856
+ from_existing_target = True
857
+ if not agent_name:
858
+ agent_name = picked_agent_name
859
+
693
860
  # Interactive picker when neither --stack nor --self-serve was passed.
694
861
  # Three branches: managed (default), self-serve, or one of the
695
862
  # registered stacks. Net-new envs without an explicit choice land
@@ -743,7 +910,7 @@ def env_create(
743
910
  sys.exit(1)
744
911
 
745
912
  if self_serve:
746
- _create_env_self_serve(profile, name, agent_name)
913
+ _create_env_self_serve(profile, name, agent_name, from_existing_target=from_existing_target)
747
914
  return
748
915
 
749
916
  # Every reachable path here leaves ``stack`` set. Stack flows own
@@ -763,6 +930,7 @@ def _create_env_self_serve(
763
930
  profile: str | None,
764
931
  name: str | None,
765
932
  agent_name: str | None,
933
+ from_existing_target: bool = False,
766
934
  ) -> None:
767
935
  """`veris env create --self-serve` — scaffold .veris/ locally, create
768
936
  a backend env in released state.
@@ -858,8 +1026,13 @@ def _create_env_self_serve(
858
1026
  # the backend today). Refuse and tell the customer how to recover.
859
1027
  # Detected BEFORE _scaffold_veris_dir so the function's
860
1028
  # no-op-on-existing-block path doesn't mask this.
1029
+ #
1030
+ # `from_existing_target` flips this off: when the caller deliberately
1031
+ # picked an already-present target block to bind a backend env to (the
1032
+ # restored v2.26.0 flow), the existing block is expected, not a failed
1033
+ # retry — binding it is the whole point.
861
1034
  veris_yaml_path = veris_dir / "veris.yaml"
862
- if veris_yaml_path.exists():
1035
+ if veris_yaml_path.exists() and not from_existing_target:
863
1036
  existing = _try_load_veris_yaml(veris_yaml_path)
864
1037
  if existing and env_name in existing.targets:
865
1038
  output.print_error(
@@ -1966,14 +2139,23 @@ def env_push(
1966
2139
  _auto_snapshot_for_stack(pinned_stack, Path.cwd())
1967
2140
 
1968
2141
  # On-prem clusters route through the customer's external registry instead of Cloud Build.
1969
- try:
1970
- if env_info.get("image_registry_url"):
2142
+ if env_info.get("image_registry_url"):
2143
+ tag_result = None
2144
+ try:
1971
2145
  tag_result = api.create_image_tag(env_id, tag)
1972
- if tag_result.get("pull_credentials"):
1973
- _env_push_local(env_id, tag, tag_result, profile, dockerfile=dockerfile_rel)
1974
- return
1975
- except Exception:
1976
- pass # Fall through to Cloud Build
2146
+ except APIError as e:
2147
+ # Entitlement / unknown-service errors are sealed server-side. Surface
2148
+ # them directly — a Cloud Build fallback can't reach the customer's
2149
+ # registry anyway, so masking them would only confuse.
2150
+ if e.status_code in (400, 403):
2151
+ output.print_error(str(e.detail))
2152
+ sys.exit(1)
2153
+ raise
2154
+ except Exception:
2155
+ tag_result = None # transient — fall through to Cloud Build below
2156
+ if tag_result and tag_result.get("build_credentials"):
2157
+ _env_push_local(env_id, tag, tag_result, profile, dockerfile=dockerfile_rel)
2158
+ return
1977
2159
 
1978
2160
  _env_push_remote(env_id, tag, profile, dockerfile=dockerfile_rel)
1979
2161
 
@@ -6,10 +6,37 @@ import time
6
6
  import click
7
7
 
8
8
  from veris_cli import output
9
- from veris_cli.api import VerisAPI
9
+ from veris_cli.api import APIError, VerisAPI
10
10
  from veris_cli.commands._helpers import get_profile, resolve_env_id
11
11
 
12
12
 
13
+ def _resolve_num(num: int | None) -> int:
14
+ """Resolve scenario count, prompting interactively on a TTY when omitted."""
15
+ if num is not None:
16
+ return num
17
+ if sys.stdin.isatty():
18
+ import questionary
19
+
20
+ answer = questionary.text(
21
+ "Number of scenarios to generate:",
22
+ default="5",
23
+ validate=lambda x: x.isdigit() and int(x) > 0,
24
+ ).ask()
25
+ return int(answer) if answer else 5
26
+ return 5
27
+
28
+
29
+ def _build_source(num: int, prompt: str | None, image_tag: str | None) -> tuple[str, dict]:
30
+ """Build the (kind, config) for a generation source. Always fire-and-forget."""
31
+ config: dict = {"num_scenarios": num, "auto_approve_on_stage": True}
32
+ if image_tag:
33
+ config["image_tag"] = image_tag
34
+ if prompt:
35
+ config["instructions"] = prompt
36
+ return "prompt", config
37
+ return "exploration", config
38
+
39
+
13
40
  @click.group()
14
41
  def scenarios():
15
42
  """Scenario management commands."""
@@ -19,13 +46,20 @@ def scenarios():
19
46
  @scenarios.command(name="create")
20
47
  @click.option("--env-id", default=None, help="Environment ID")
21
48
  @click.option("--num", default=None, type=int, help="Number of scenarios to generate")
49
+ @click.option(
50
+ "--prompt",
51
+ default=None,
52
+ help="Generate from a natural-language prompt instead of agent exploration",
53
+ )
54
+ @click.option("--title", default="Set", help="Title for the new scenario set")
22
55
  @click.option("--image-tag", default=None, help="Image tag to use (default: latest)")
23
56
  @click.pass_context
24
- def scenarios_create(ctx, env_id: str, num: int | None, image_tag: str):
25
- """Generate scenario set via async job.
57
+ def scenarios_create(ctx, env_id: str, num: int | None, prompt: str, title: str, image_tag: str):
58
+ """Generate a scenario set via async job.
26
59
 
27
- Launches an async job that explores your agent code, generates test
28
- scenarios and a grader definition.
60
+ Explores your agent code to generate test scenarios and a grader. Pass
61
+ --prompt to generate from a natural-language description instead. Runs
62
+ fire-and-forget — watch progress with `veris scenarios status <set_id> --watch`.
29
63
  """
30
64
  profile = get_profile(ctx)
31
65
  try:
@@ -34,27 +68,29 @@ def scenarios_create(ctx, env_id: str, num: int | None, image_tag: str):
34
68
  if not env_id:
35
69
  env_id = resolve_env_id(profile, env_id)
36
70
 
37
- if num is None:
38
- if sys.stdin.isatty():
39
- import questionary
40
-
41
- answer = questionary.text(
42
- "Number of scenarios to generate:",
43
- default="5",
44
- validate=lambda x: x.isdigit() and int(x) > 0,
45
- ).ask()
46
- num = int(answer) if answer else 5
47
- else:
48
- num = 5
49
-
50
- output.print_info(f"Generating {num} scenario(s) for environment {env_id}...")
51
- result = api.generate_scenario_set(
52
- environment_id=env_id,
53
- num_scenarios=num,
54
- image_tag=image_tag,
71
+ num = _resolve_num(num)
72
+ kind, config = _build_source(num, prompt, image_tag)
73
+
74
+ source_label = "from prompt" if prompt else "via exploration"
75
+ output.print_info(
76
+ f"Generating {num} scenario(s) {source_label} for environment {env_id}..."
55
77
  )
78
+ scenario_set = api.create_scenario_set(title=title, environment_id=env_id)
79
+ set_id = scenario_set.get("id", "")
80
+ try:
81
+ api.create_scenario_source(set_id, kind, config)
82
+ except Exception as exc:
83
+ # Source launch failed — roll back the empty set we just created so
84
+ # scripted/looped `create` calls don't accumulate orphans. Best-effort:
85
+ # never let cleanup mask the original failure. Skip on 409 (busy) so we
86
+ # never delete a set that already has an active generation.
87
+ if not (isinstance(exc, APIError) and exc.status_code == 409):
88
+ try:
89
+ api.delete_scenario_set(set_id)
90
+ except Exception:
91
+ pass
92
+ raise
56
93
 
57
- set_id = result.get("id", "")
58
94
  output.print_success(f"Scenario generation started: {set_id}")
59
95
  output.print_info(f"→ Next: veris scenarios status {set_id} --watch")
60
96
 
@@ -66,6 +102,53 @@ def scenarios_create(ctx, env_id: str, num: int | None, image_tag: str):
66
102
  sys.exit(1)
67
103
 
68
104
 
105
+ @scenarios.command(name="add")
106
+ @click.argument("set_id")
107
+ @click.option("--num", default=None, type=int, help="Number of scenarios to generate")
108
+ @click.option(
109
+ "--prompt",
110
+ default=None,
111
+ help="Generate from a natural-language prompt instead of agent exploration",
112
+ )
113
+ @click.option("--image-tag", default=None, help="Image tag to use (default: latest)")
114
+ @click.pass_context
115
+ def scenarios_add(ctx, set_id: str, num: int | None, prompt: str, image_tag: str):
116
+ """Add more scenarios to an existing set.
117
+
118
+ Launches another generation source under SET_ID. Runs fire-and-forget —
119
+ watch progress with `veris scenarios status <set_id> --watch`.
120
+ """
121
+ profile = get_profile(ctx)
122
+ try:
123
+ api = VerisAPI(profile=profile)
124
+
125
+ num = _resolve_num(num)
126
+ kind, config = _build_source(num, prompt, image_tag)
127
+
128
+ source_label = "from prompt" if prompt else "via exploration"
129
+ output.print_info(f"Adding {num} scenario(s) {source_label} to set {set_id}...")
130
+ api.create_scenario_source(set_id, kind, config)
131
+
132
+ output.print_success(f"Scenario generation started: {set_id}")
133
+ output.print_info(f"→ Next: veris scenarios status {set_id} --watch")
134
+
135
+ except APIError as e:
136
+ if e.status_code == 409:
137
+ output.print_error(
138
+ "This scenario set is currently generating. Please wait for the "
139
+ "in-progress generation to finish before adding more scenarios."
140
+ )
141
+ else:
142
+ output.print_error(f"Failed to add scenarios: {e.detail}")
143
+ sys.exit(1)
144
+ except ValueError as e:
145
+ output.print_error(str(e))
146
+ sys.exit(1)
147
+ except Exception as e:
148
+ output.print_error(f"Failed to add scenarios: {e}")
149
+ sys.exit(1)
150
+
151
+
69
152
  @scenarios.command(name="status")
70
153
  @click.argument("set_id")
71
154
  @click.option("--watch", is_flag=True, help="Poll until generation completes")
File without changes
File without changes