nooa-cli 0.0.7__py3-none-any.whl

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.
@@ -0,0 +1,356 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """``nooa config`` — inspect and customize the project's config files.
4
+
5
+ Subcommands:
6
+
7
+ - ``show`` — print the resolved layers for ``llm_config.yaml``,
8
+ ``settings.yaml``, and ``secrets.yaml`` (secret values
9
+ redacted — only key names shown).
10
+ - ``path`` — print the user-level YAML path (where ``eject`` writes).
11
+ - ``eject`` — copy a bundled-defaults YAML to the user-level path so
12
+ you can edit it locally.
13
+
14
+ All three files share the same directories and precedence (user →
15
+ project → env-var override); see :mod:`nooa.layered_config`.
16
+
17
+ The config chain (last wins):
18
+
19
+ 1. Bundled defaults — every package that registers under the
20
+ ``nooa.bundled_configs`` entry-point group. Install
21
+ ``nemo-oo-agents-nvidia`` for the NVIDIA-gateway aliases.
22
+ 2. User config at ``~/.config/nooa/llm_config.yaml``
23
+ (overridable via ``NEMO_OO_USER_DIR``).
24
+ 3. Project-local ``.nooa/llm_config.yaml``.
25
+ 4. ``NEMO_OO_LLM_CONFIG`` env var — comma-separated YAML paths;
26
+ the global override, highest priority.
27
+ """
28
+
29
+ import os
30
+ import shutil
31
+ from pathlib import Path
32
+
33
+ import click
34
+
35
+
36
+ def _user_config_path() -> Path:
37
+ """Where ``eject`` writes and the registry's user layer reads from."""
38
+ from nooa.paths import get_user_dir
39
+
40
+ return get_user_dir("llm_config.yaml")
41
+
42
+
43
+ def _project_config_path() -> Path:
44
+ """Where the registry's project-local layer reads from."""
45
+ from nooa.paths import get_project_dir
46
+
47
+ return get_project_dir("llm_config.yaml")
48
+
49
+
50
+ def _count_aliases(path: Path) -> int | None:
51
+ """Return the count of ``models:`` entries in a YAML file, or None on error."""
52
+ try:
53
+ import yaml
54
+
55
+ data = yaml.safe_load(path.read_text())
56
+ except Exception:
57
+ return None
58
+ if not isinstance(data, dict):
59
+ return 0
60
+ models = data.get("models")
61
+ if not isinstance(models, dict):
62
+ return 0
63
+ # Count non-None entries (null entries are removal directives, not aliases).
64
+ return sum(1 for v in models.values() if v is not None)
65
+
66
+
67
+ @click.group()
68
+ def command():
69
+ """Inspect and customize the LLM-registry config chain."""
70
+
71
+
72
+ @command.command("show")
73
+ def show_cmd():
74
+ """Print the resolved config-file chain.
75
+
76
+ Useful for debugging "why is my model alias not found" — shows each
77
+ layer, whether it's present, and how many aliases it contributes.
78
+
79
+ For each candidate layer, output includes a "(deduplicated …)" note
80
+ if the file's resolved path is *also* reachable through a
81
+ higher-priority layer — :func:`llm_config_chain` keeps only the
82
+ higher slot, so the lower one isn't actually loaded.
83
+ """
84
+ from nooa.llm_config import (
85
+ bundled_config_paths,
86
+ llm_config_chain,
87
+ )
88
+
89
+ # Resolve the chain once up-front so each candidate line below can
90
+ # accurately note whether it survived deduplication.
91
+ resolved_chain = llm_config_chain()
92
+ resolved_set = {p.resolve() for p in resolved_chain}
93
+
94
+ def _dedup_suffix(candidate_path: Path) -> str:
95
+ """Return ``""`` if loaded, else a "(deduplicated …)" annotation."""
96
+ try:
97
+ return (
98
+ ""
99
+ if candidate_path.resolve() in resolved_set
100
+ else " (deduplicated — same resolved path as a higher-priority layer)"
101
+ )
102
+ except OSError:
103
+ return ""
104
+
105
+ # Layer 1 — bundled defaults (entry-point providers)
106
+ click.echo("Bundled defaults:")
107
+ bundled = bundled_config_paths()
108
+ if not bundled:
109
+ click.echo(" (none registered — install nemo-oo-agents-nvidia or another provider)")
110
+ else:
111
+ for b in bundled:
112
+ n = _count_aliases(b)
113
+ alias_suffix = f"({n} aliases)" if n is not None else "(unreadable)"
114
+ click.echo(f" {b} {alias_suffix}{_dedup_suffix(b)}")
115
+
116
+ # Layer 2 — user
117
+ user_path = _user_config_path()
118
+ click.echo(f"User config ({user_path}):")
119
+ if user_path.exists():
120
+ n = _count_aliases(user_path)
121
+ suffix = f" ({n} aliases)" if n is not None else " (unreadable)"
122
+ click.echo(f" present{suffix}{_dedup_suffix(user_path)}")
123
+ else:
124
+ click.echo(" not present")
125
+
126
+ # Layer 3 — project-local
127
+ project_path = _project_config_path()
128
+ click.echo(f"Project config ({project_path}):")
129
+ if project_path.exists():
130
+ n = _count_aliases(project_path)
131
+ suffix = f" ({n} aliases)" if n is not None else " (unreadable)"
132
+ click.echo(f" present{suffix}{_dedup_suffix(project_path)}")
133
+ else:
134
+ click.echo(" not present")
135
+
136
+ # Layer 4 — env var (global override, highest priority)
137
+ env_val = os.environ.get("NEMO_OO_LLM_CONFIG", "").strip()
138
+ click.echo("NEMO_OO_LLM_CONFIG (global override):")
139
+ if not env_val:
140
+ click.echo(" not set")
141
+ else:
142
+ for raw in env_val.split(","):
143
+ raw = raw.strip()
144
+ if not raw:
145
+ continue
146
+ p = Path(raw).expanduser().resolve()
147
+ if p.exists():
148
+ n = _count_aliases(p)
149
+ suffix = f" ({n} aliases)" if n is not None else " (unreadable)"
150
+ click.echo(f" {p}{suffix}{_dedup_suffix(p)}")
151
+ else:
152
+ click.echo(f" {p} (does not exist)")
153
+
154
+ # Total — populate MODELS via the same resolved chain so the count
155
+ # is consistent with the dedup-annotated layers above.
156
+ from nooa.unifiedllm import MODELS, reload_registry
157
+
158
+ reload_registry(*resolved_chain)
159
+ click.echo("")
160
+ click.echo(f"Total: {len(MODELS)} model aliases registered")
161
+
162
+ # Surface which API key env vars the loaded aliases reference, and
163
+ # whether each is set. This is the "why isn't my key working"
164
+ # diagnostic.
165
+ referenced = {
166
+ cfg["api_key_env"]
167
+ for cfg in MODELS.values()
168
+ if isinstance(cfg, dict) and cfg.get("api_key_env")
169
+ }
170
+ if referenced:
171
+ click.echo("")
172
+ click.echo("API key env vars referenced:")
173
+ for var in sorted(referenced):
174
+ present = "set" if os.environ.get(var) else "NOT SET"
175
+ click.echo(f" {var:30s} ({present})")
176
+
177
+ # ── Settings & secrets (same dirs, same precedence) ───────────────
178
+ click.echo("")
179
+ _show_layered_file("Settings", "settings.yaml", "NEMO_OO_SETTINGS", _summarize_settings)
180
+ click.echo("")
181
+ _show_layered_file("Secrets", "secrets.yaml", "NEMO_OO_SECRETS", _summarize_secrets)
182
+
183
+
184
+ def _summarize_settings(path: Path) -> str:
185
+ """One-line summary of a settings.yaml: top-level sections + key count."""
186
+ try:
187
+ import yaml
188
+
189
+ data = yaml.safe_load(path.read_text())
190
+ except Exception:
191
+ return "(unreadable)"
192
+ if not isinstance(data, dict) or not data:
193
+ return "(empty)"
194
+ parts = []
195
+ for section in ("tui", "agent"):
196
+ sect = data.get(section)
197
+ if isinstance(sect, dict):
198
+ parts.append(f"{section}: {len(sect)} keys")
199
+ return ", ".join(parts) if parts else f"{len(data)} keys"
200
+
201
+
202
+ def _summarize_secrets(path: Path) -> str:
203
+ """One-line summary of a secrets.yaml: env var NAMES only (values redacted)."""
204
+ try:
205
+ import yaml
206
+
207
+ data = yaml.safe_load(path.read_text())
208
+ except Exception:
209
+ return "(unreadable)"
210
+ if not isinstance(data, dict):
211
+ return "(empty)"
212
+ env_map = data.get("env")
213
+ if not isinstance(env_map, dict) or not env_map:
214
+ return "(no env: keys)"
215
+ names = sorted(str(k) for k in env_map)
216
+ return "env: " + ", ".join(names) + " (values redacted)"
217
+
218
+
219
+ def _show_layered_file(label: str, filename: str, env_var: str, summarize) -> None:
220
+ """Print the user → project → env-var layers for *filename*.
221
+
222
+ *summarize* is called per existing file to produce a one-line,
223
+ value-safe description. The highest-priority occurrence of a resolved
224
+ path wins; lower occurrences are flagged as shadowed.
225
+ """
226
+ from nooa.layered_config import layered_paths
227
+ from nooa.paths import get_project_dir, get_user_dir
228
+
229
+ resolved_set = set(layered_paths(filename, env_var))
230
+
231
+ def _summary(path: Path) -> str:
232
+ try:
233
+ note = "" if path.resolve() in resolved_set else " (shadowed by a higher layer)"
234
+ except OSError:
235
+ note = ""
236
+ return f"present — {summarize(path)}{note}"
237
+
238
+ click.echo(f"{label} ({filename}):")
239
+
240
+ user_path = get_user_dir(filename)
241
+ project_path = get_project_dir(filename)
242
+ for layer, path in (("User", user_path), ("Project", project_path)):
243
+ click.echo(f" {layer} ({path}):")
244
+ click.echo(f" {_summary(path)}" if path.exists() else " not present")
245
+
246
+ env_val = os.environ.get(env_var, "").strip()
247
+ click.echo(f" {env_var} (override):")
248
+ if not env_val:
249
+ click.echo(" not set")
250
+ return
251
+ for raw in env_val.split(","):
252
+ raw = raw.strip()
253
+ if not raw:
254
+ continue
255
+ p = Path(raw).expanduser()
256
+ click.echo(f" {p}: {_summary(p)}" if p.exists() else f" {p} (does not exist)")
257
+
258
+
259
+ @command.command("path")
260
+ def path_cmd():
261
+ """Print the user-level config path (where ``eject`` writes)."""
262
+ click.echo(str(_user_config_path()))
263
+
264
+
265
+ @command.command("eject")
266
+ @click.option(
267
+ "--force",
268
+ is_flag=True,
269
+ help="Overwrite an existing user-level config file.",
270
+ )
271
+ def eject_cmd(force: bool):
272
+ """Copy a bundled-defaults YAML to the user-level config path.
273
+
274
+ The user-level path is ``~/.config/nooa/llm_config.yaml`` (XDG;
275
+ override with ``NEMO_OO_USER_DIR``). Bundled defaults still load
276
+ as a lower-priority layer, so the ejected copy *adds* to /
277
+ overrides on top of them — you can prune the ejected file to just
278
+ the aliases you want to customize.
279
+
280
+ When multiple bundled-config providers are registered, ``eject``
281
+ refuses (rather than silently picking one). Install only the one
282
+ you want to use as the editing baseline, or write a hand-built
283
+ ``llm_config.yaml`` directly.
284
+ """
285
+ from nooa.llm_config import bundled_config_paths
286
+
287
+ target = _user_config_path()
288
+ if target.exists() and not force:
289
+ click.echo(
290
+ f"Refusing to overwrite existing file: {target}\nRe-run with --force to overwrite.",
291
+ err=True,
292
+ )
293
+ raise SystemExit(1)
294
+
295
+ bundled = bundled_config_paths()
296
+ if not bundled:
297
+ click.echo(
298
+ "No bundled-defaults provider is installed. Nothing to eject.\n"
299
+ "Install `nemo-oo-agents-nvidia` (or another provider) first.",
300
+ err=True,
301
+ )
302
+ raise SystemExit(1)
303
+ if len(bundled) > 1:
304
+ formatted = "\n ".join(str(b) for b in bundled)
305
+ click.echo(
306
+ "Multiple bundled-defaults providers are registered; refusing to "
307
+ "pick one to eject. Providers:\n " + formatted + "\n"
308
+ "Uninstall all but the one you want as the eject baseline.",
309
+ err=True,
310
+ )
311
+ raise SystemExit(1)
312
+ source = bundled[0]
313
+
314
+ # Refuse to clobber a symlink target — managed-dotfiles users
315
+ # routinely symlink ~/.config files into a versioned dir;
316
+ # ``shutil.copyfile`` would silently write through the symlink to
317
+ # the upstream target.
318
+ if target.is_symlink():
319
+ click.echo(
320
+ f"Target is a symlink ({target} → {os.readlink(target)}); refusing to overwrite.\n"
321
+ "Remove the symlink first, or write to a different path via NEMO_OO_USER_DIR.",
322
+ err=True,
323
+ )
324
+ raise SystemExit(1)
325
+
326
+ # Refuse to clobber a directory at the target path.
327
+ if target.is_dir():
328
+ click.echo(
329
+ f"Target path is a directory: {target}\nRemove or rename it first.",
330
+ err=True,
331
+ )
332
+ raise SystemExit(1)
333
+
334
+ try:
335
+ target.parent.mkdir(parents=True, exist_ok=True)
336
+ except OSError as exc:
337
+ click.echo(f"Cannot create parent dir {target.parent}: {exc}", err=True)
338
+ raise SystemExit(1) from exc
339
+
340
+ try:
341
+ shutil.copyfile(source, target)
342
+ except OSError as exc:
343
+ click.echo(f"Cannot write to {target}: {exc}", err=True)
344
+ raise SystemExit(1) from exc
345
+
346
+ click.echo(f"Wrote {target}")
347
+ click.echo(
348
+ "Edit it to customize. `nooa` will load it as a layer on top of the bundled defaults."
349
+ )
350
+
351
+ if os.environ.get("NEMO_OO_LLM_CONFIG", "").strip():
352
+ click.echo("")
353
+ click.echo(
354
+ "Note: $NEMO_OO_LLM_CONFIG is set; paths in that variable load *after* the file you "
355
+ "just wrote and may override it. Run `nooa config show` to inspect resolution."
356
+ )
@@ -0,0 +1,63 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Delete imported trace batches from the viewer.
4
+
5
+ Usage:
6
+ nooa delete-traces --batch-id my-experiment-v2
7
+ nooa delete-traces --batch-id my-experiment-v2 --endpoint http://host:5001
8
+ """
9
+
10
+ import json
11
+ import urllib.parse
12
+ import urllib.request
13
+
14
+ import click
15
+
16
+ NAME = "delete-traces"
17
+
18
+
19
+ def _validate_endpoint(endpoint: str) -> None:
20
+ """Validate that the endpoint uses an HTTP(S) scheme."""
21
+ parsed = urllib.parse.urlparse(endpoint)
22
+ if parsed.scheme not in ("http", "https"):
23
+ raise click.BadParameter(
24
+ f"Endpoint must use http:// or https:// scheme, got: {endpoint}",
25
+ param_hint="'--endpoint'",
26
+ )
27
+
28
+
29
+ @click.command()
30
+ @click.option(
31
+ "--batch-id",
32
+ required=True,
33
+ help="Batch ID to delete.",
34
+ )
35
+ @click.option(
36
+ "--endpoint",
37
+ default="http://localhost:5001",
38
+ show_default=True,
39
+ help="Viewer API endpoint.",
40
+ )
41
+ def command(batch_id: str, endpoint: str):
42
+ """Delete all traces belonging to a batch."""
43
+ _validate_endpoint(endpoint)
44
+
45
+ # Verify endpoint is reachable
46
+ try:
47
+ with urllib.request.urlopen(f"{endpoint.rstrip('/')}/api/version", timeout=5):
48
+ pass
49
+ except Exception:
50
+ click.echo(f"Cannot reach viewer at {endpoint}. Is it running?")
51
+ raise SystemExit(1) from None
52
+
53
+ url = f"{endpoint.rstrip('/')}/api/traces?batch_id={urllib.parse.quote(batch_id, safe='')}"
54
+ req = urllib.request.Request(url, method="DELETE")
55
+
56
+ try:
57
+ with urllib.request.urlopen(req, timeout=10) as resp:
58
+ result = json.loads(resp.read())
59
+ count = result.get("deleted", 0)
60
+ click.echo(f"Deleted {count} trace(s) from batch '{batch_id}'")
61
+ except Exception as e:
62
+ click.echo(f"Failed to delete batch '{batch_id}': {e}")
63
+ raise SystemExit(1) from None
@@ -0,0 +1,68 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Run an evaluation pipeline job.
4
+
5
+ Thin passthrough to `python -m eval_pipeline`. All arguments are forwarded
6
+ as-is, so this command always stays in sync with eval_pipeline's own CLI.
7
+
8
+ Usage:
9
+ nooa eval --config config.yaml
10
+ nooa eval --config config.yaml --runs 3 --parallel 10
11
+ nooa eval --config config.yaml --test sentiment --limit 5
12
+ """
13
+
14
+ import click
15
+
16
+
17
+ @click.command(
18
+ add_help_option=False,
19
+ context_settings={
20
+ "ignore_unknown_options": True,
21
+ "allow_extra_args": True,
22
+ },
23
+ )
24
+ @click.argument("args", nargs=-1, type=click.UNPROCESSED)
25
+ @click.pass_context
26
+ def command(ctx: click.Context, args: tuple[str, ...]):
27
+ """Run an evaluation pipeline (passthrough to eval_pipeline).
28
+
29
+ All arguments are forwarded directly to eval_pipeline's CLI.
30
+
31
+ \b
32
+ Examples:
33
+ nooa eval --config config.yaml
34
+ nooa eval --config config.yaml --runs 3 --parallel 10
35
+ nooa eval --config config.yaml --test sentiment --limit 5
36
+ nooa eval --config config.yaml --models gpt-4,claude-3 -q
37
+ nooa eval --config config.yaml --default_strategy codeact
38
+ nooa eval --help
39
+ """
40
+ import asyncio
41
+ import faulthandler
42
+ import signal
43
+ import sys
44
+
45
+ # Enable faulthandler (same as eval_pipeline.__main__)
46
+ faulthandler.enable()
47
+ if hasattr(signal, "SIGUSR1"):
48
+ faulthandler.register(signal.SIGUSR1, file=sys.stderr, all_threads=True)
49
+
50
+ # Forward all arguments directly to eval_pipeline
51
+ old_argv = sys.argv
52
+ sys.argv = ["eval_pipeline", *args]
53
+ try:
54
+ try:
55
+ from eval_pipeline.cli import main_async
56
+ except ImportError:
57
+ click.echo(
58
+ "The eval command requires the 'eval_pipeline' package, which is "
59
+ "not installed.\nIt ships with the nemo-oo-agents monorepo workspace "
60
+ "but is not a dependency of the standalone CLI wheel.\n"
61
+ "Install it from a monorepo checkout with: uv sync",
62
+ err=True,
63
+ )
64
+ sys.exit(1)
65
+
66
+ asyncio.run(main_async())
67
+ finally:
68
+ sys.argv = old_argv