nasde-toolkit 0.3.2__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.
nasde_toolkit/cli.py ADDED
@@ -0,0 +1,513 @@
1
+ """CLI entry point for nasde.
2
+
3
+ Core commands (run, eval, init) use Harbor/Opik Python APIs directly.
4
+ Pass-through commands (harbor, opik) delegate to the respective CLIs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ import click
14
+ import typer
15
+ from rich.console import Console
16
+ from rich.panel import Panel
17
+ from typer.core import TyperGroup
18
+
19
+ if TYPE_CHECKING:
20
+ from nasde_toolkit.config import ProjectConfig
21
+
22
+ console = Console()
23
+
24
+
25
+ class _BannerGroup(TyperGroup):
26
+ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
27
+ from nasde_toolkit.banner import print_banner
28
+
29
+ print_banner(console)
30
+ super().format_help(ctx, formatter)
31
+
32
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
33
+ cmd = super().get_command(ctx, cmd_name)
34
+ if cmd is not None:
35
+ original_format_help = cmd.format_help
36
+
37
+ def patched_format_help(ctx: click.Context, formatter: click.HelpFormatter) -> None:
38
+ from nasde_toolkit.banner import print_banner
39
+
40
+ print_banner(console)
41
+ original_format_help(ctx, formatter)
42
+
43
+ cmd.format_help = patched_format_help # type: ignore[assignment]
44
+ return cmd
45
+
46
+
47
+ app = typer.Typer(
48
+ name="nasde",
49
+ help="Noesis Agentic Software Development Evals Toolkit",
50
+ rich_markup_mode="rich",
51
+ cls=_BannerGroup,
52
+ )
53
+
54
+
55
+ def _version_callback(value: bool) -> None:
56
+ if value:
57
+ from nasde_toolkit.banner import print_banner
58
+
59
+ print_banner(console)
60
+ raise typer.Exit()
61
+
62
+
63
+ def _no_banner_callback(value: bool) -> None:
64
+ if value:
65
+ from nasde_toolkit.banner import suppress_banner
66
+
67
+ suppress_banner()
68
+
69
+
70
+ @app.callback(invoke_without_command=True)
71
+ def main(
72
+ ctx: typer.Context,
73
+ version: bool = typer.Option(
74
+ False,
75
+ "--version",
76
+ "-v",
77
+ help="Show version and exit.",
78
+ callback=_version_callback,
79
+ is_eager=True,
80
+ ),
81
+ no_banner: bool = typer.Option(
82
+ False,
83
+ "--no-banner",
84
+ help="Suppress ASCII banner. Also: NASDE_NO_BANNER=1.",
85
+ callback=_no_banner_callback,
86
+ is_eager=True,
87
+ ),
88
+ ) -> None:
89
+ """Noesis Agentic Software Development Evals Toolkit."""
90
+ from nasde_toolkit._version import __version__
91
+ from nasde_toolkit.update_check import maybe_notify_update
92
+
93
+ maybe_notify_update(console, current_version=__version__)
94
+ if ctx.invoked_subcommand is None:
95
+ console.print(ctx.get_help())
96
+ raise typer.Exit()
97
+
98
+
99
+ @app.command()
100
+ def init(
101
+ project_dir: Path = typer.Argument(
102
+ Path("."),
103
+ help="Directory to scaffold the evaluation project in.",
104
+ ),
105
+ name: str = typer.Option(
106
+ "",
107
+ "--name",
108
+ "-n",
109
+ help="Project name (defaults to directory name).",
110
+ ),
111
+ ) -> None:
112
+ """Scaffold a new evaluation project."""
113
+ from nasde_toolkit.banner import print_banner
114
+ from nasde_toolkit.scaffold import create_project
115
+
116
+ print_banner(console)
117
+ project_name = name or project_dir.resolve().name
118
+ create_project(project_dir.resolve(), project_name)
119
+
120
+
121
+ @app.command(name="install-skills")
122
+ def install_skills(
123
+ scope: str = typer.Option(
124
+ "user",
125
+ "--scope",
126
+ help="Where to install: 'user' (~/.claude/skills) or 'project' (./.claude/skills).",
127
+ ),
128
+ target_dir: Path | None = typer.Option(
129
+ None,
130
+ "--target-dir",
131
+ help="Custom skills directory (overrides --scope).",
132
+ ),
133
+ force: bool = typer.Option(
134
+ False,
135
+ "--force",
136
+ "-f",
137
+ help="Overwrite existing skill files.",
138
+ ),
139
+ ) -> None:
140
+ """Install NASDE authoring skills into a Claude Code skills directory."""
141
+ from nasde_toolkit.banner import print_banner
142
+ from nasde_toolkit.skills_installer import install_bundled_skills
143
+
144
+ print_banner(console)
145
+ install_bundled_skills(console=console, scope=scope, target_dir=target_dir, force=force)
146
+
147
+
148
+ @app.command()
149
+ def run(
150
+ variant: str | None = typer.Option(
151
+ None,
152
+ "--variant",
153
+ help="Variant to run (defaults to config default).",
154
+ ),
155
+ all_variants: bool = typer.Option(
156
+ False,
157
+ "--all-variants",
158
+ help="Run all available variants (Cartesian product with tasks).",
159
+ ),
160
+ tasks: str | None = typer.Option(
161
+ None,
162
+ "--tasks",
163
+ help="Comma-separated task names to run.",
164
+ ),
165
+ model: str | None = typer.Option(
166
+ None,
167
+ "--model",
168
+ help="Model override.",
169
+ ),
170
+ timeout: int | None = typer.Option(
171
+ None,
172
+ "--timeout",
173
+ help="Agent timeout in seconds.",
174
+ ),
175
+ with_opik: bool = typer.Option(
176
+ False,
177
+ "--with-opik",
178
+ help="Enable Opik tracing.",
179
+ ),
180
+ attempts: int = typer.Option(
181
+ 1,
182
+ "--attempts",
183
+ "-n",
184
+ help="Number of independent attempts per task (Harbor n_attempts).",
185
+ ),
186
+ without_eval: bool = typer.Option(
187
+ False,
188
+ "--without-eval",
189
+ help="Skip assessment evaluation after benchmark.",
190
+ ),
191
+ job_suffix: str | None = typer.Option(
192
+ None,
193
+ "--job-suffix",
194
+ help="Custom suffix for job directory name (default: random 6-char hex).",
195
+ ),
196
+ max_concurrent_eval: int = typer.Option(
197
+ 10,
198
+ "--max-concurrent-eval",
199
+ help="Max concurrent assessment evaluations (default: 10).",
200
+ ),
201
+ harbor_env: str | None = typer.Option(
202
+ None,
203
+ "--harbor-env",
204
+ help="Harbor execution environment (docker, daytona, modal, e2b, runloop, gke). Default: docker.",
205
+ ),
206
+ project_dir: Path = typer.Option(
207
+ Path("."),
208
+ "--project-dir",
209
+ "-C",
210
+ help="Path to evaluation project.",
211
+ ),
212
+ ) -> None:
213
+ """Run benchmark tasks via Harbor."""
214
+ if variant and all_variants:
215
+ console.print("[red]ERROR: --variant and --all-variants are mutually exclusive.[/red]")
216
+ raise typer.Exit(1)
217
+
218
+ if not variant and not all_variants:
219
+ console.print("[red]ERROR: Specify --variant NAME or --all-variants.[/red]")
220
+ raise typer.Exit(1)
221
+
222
+ from nasde_toolkit.config import load_project_config
223
+ from nasde_toolkit.runner import collect_available_variants, run_benchmark
224
+
225
+ config = load_project_config(project_dir.resolve())
226
+ tasks_filter = [t.strip() for t in tasks.split(",")] if tasks else None
227
+ resolved_harbor_env = harbor_env or config.default_harbor_env
228
+ resolved_model = model
229
+ resolved_timeout = timeout
230
+
231
+ if all_variants:
232
+ variants_list = collect_available_variants(config.project_dir)
233
+ if not variants_list:
234
+ console.print("[red]ERROR: No variants found.[/red]")
235
+ raise typer.Exit(1)
236
+
237
+ _confirm_multi_variant_run(
238
+ variants=variants_list,
239
+ config=config,
240
+ tasks_filter=tasks_filter,
241
+ attempts=attempts,
242
+ )
243
+
244
+ # TODO: Parallel variant execution is possible but requires refactoring
245
+ # _run_job() to eliminate os.chdir() and isolate Opik tracking.
246
+ asyncio.run(
247
+ _run_all_variants(
248
+ config=config,
249
+ variants=variants_list,
250
+ model=resolved_model,
251
+ timeout_sec=resolved_timeout,
252
+ tasks_filter=tasks_filter,
253
+ with_opik=with_opik,
254
+ with_eval=not without_eval,
255
+ harbor_env=resolved_harbor_env,
256
+ n_attempts=attempts,
257
+ job_suffix=job_suffix,
258
+ max_concurrent_eval=max_concurrent_eval,
259
+ )
260
+ )
261
+ else:
262
+ assert variant is not None
263
+ resolved_variant = variant
264
+ from nasde_toolkit.runner import _resolve_model, load_variant_agent_type, resolve_variant_dir
265
+
266
+ variant_dir = resolve_variant_dir(config.project_dir, resolved_variant)
267
+ agent_type = load_variant_agent_type(variant_dir)
268
+ display_model = _resolve_model(resolved_model, variant_dir, config)
269
+
270
+ _print_run_header(
271
+ variant=resolved_variant,
272
+ model=display_model,
273
+ timeout=resolved_timeout,
274
+ tasks_filter=tasks_filter,
275
+ with_opik=with_opik,
276
+ with_eval=not without_eval,
277
+ harbor_env=resolved_harbor_env,
278
+ attempts=attempts,
279
+ agent_type=agent_type,
280
+ )
281
+
282
+ asyncio.run(
283
+ run_benchmark(
284
+ config=config,
285
+ variant=resolved_variant,
286
+ model=resolved_model,
287
+ timeout_sec=resolved_timeout,
288
+ tasks_filter=tasks_filter,
289
+ with_opik=with_opik,
290
+ with_eval=not without_eval,
291
+ harbor_env=resolved_harbor_env,
292
+ n_attempts=attempts,
293
+ job_suffix=job_suffix,
294
+ max_concurrent_eval=max_concurrent_eval,
295
+ )
296
+ )
297
+
298
+
299
+ @app.command(name="eval")
300
+ def eval_command(
301
+ job_dir: Path = typer.Argument(
302
+ ...,
303
+ help="Path to job directory to evaluate.",
304
+ ),
305
+ with_opik: bool = typer.Option(
306
+ False,
307
+ "--with-opik",
308
+ help="Upload scores to Opik.",
309
+ ),
310
+ max_concurrent_eval: int = typer.Option(
311
+ 10,
312
+ "--max-concurrent-eval",
313
+ help="Max concurrent assessment evaluations (default: 10).",
314
+ ),
315
+ project_dir: Path = typer.Option(
316
+ Path("."),
317
+ "--project-dir",
318
+ "-C",
319
+ help="Path to evaluation project.",
320
+ ),
321
+ ) -> None:
322
+ """Run post-hoc assessment of trial artifacts."""
323
+ from nasde_toolkit.config import load_project_config
324
+ from nasde_toolkit.evaluator import evaluate_job
325
+
326
+ config = load_project_config(project_dir.resolve())
327
+
328
+ from nasde_toolkit.banner import print_banner
329
+
330
+ print_banner(console)
331
+ console.print(
332
+ Panel(
333
+ f"[bold]Assessment Evaluation[/bold]\nJob: {job_dir}\nOpik: {'enabled' if with_opik else 'disabled'}",
334
+ title="nasde",
335
+ )
336
+ )
337
+
338
+ asyncio.run(
339
+ evaluate_job(
340
+ job_dir=job_dir.resolve(),
341
+ project_root=config.project_dir,
342
+ project_name=config.reporting.project_name,
343
+ with_opik=with_opik,
344
+ max_concurrent=max_concurrent_eval,
345
+ eval_config=config.evaluation,
346
+ )
347
+ )
348
+
349
+
350
+ # ---------------------------------------------------------------------------
351
+ # Harbor pass-through (Typer → Typer)
352
+ # ---------------------------------------------------------------------------
353
+
354
+ from harbor.cli.main import app as harbor_app
355
+
356
+ app.add_typer(harbor_app, name="harbor", help="Harbor CLI (pass-through).")
357
+
358
+
359
+ # ---------------------------------------------------------------------------
360
+ # Opik pass-through (Click → Typer via ctx.args)
361
+ # ---------------------------------------------------------------------------
362
+
363
+
364
+ @app.command(
365
+ name="opik",
366
+ context_settings={"allow_extra_args": True, "allow_interspersed_args": False},
367
+ )
368
+ def opik_passthrough(ctx: typer.Context) -> None:
369
+ """Opik CLI commands (pass-through)."""
370
+ from opik.cli.main import cli as opik_cli
371
+
372
+ opik_cli(ctx.args, standalone_mode=False)
373
+
374
+
375
+ # ---------------------------------------------------------------------------
376
+ # Display helpers
377
+ # ---------------------------------------------------------------------------
378
+
379
+
380
+ def _print_run_header(
381
+ variant: str,
382
+ model: str | None,
383
+ timeout: int | None,
384
+ tasks_filter: list[str] | None,
385
+ with_opik: bool,
386
+ with_eval: bool,
387
+ harbor_env: str | None = None,
388
+ attempts: int = 1,
389
+ agent_type: str = "claude",
390
+ ) -> None:
391
+ from nasde_toolkit.banner import print_banner
392
+
393
+ print_banner(console)
394
+
395
+ tasks_str = ", ".join(tasks_filter) if tasks_filter else "all"
396
+ eval_str = "enabled" if with_eval else "[yellow]disabled[/yellow]"
397
+ env_str = harbor_env or "docker"
398
+ attempts_str = f"{attempts}" if attempts > 1 else "1"
399
+ timeout_str = f"{timeout}s (override)" if timeout is not None else "per task.toml"
400
+ agent_labels = {"codex": "Codex (OpenAI)", "gemini": "Gemini CLI (Google)"}
401
+ agent_label = agent_labels.get(agent_type, "Claude Code")
402
+ console.print(
403
+ Panel(
404
+ f"[bold]Benchmark Runner[/bold]\n"
405
+ f"Agent: {agent_label}\n"
406
+ f"Variant: {variant}\n"
407
+ f"Model: {model}\n"
408
+ f"Timeout: {timeout_str}\n"
409
+ f"Tasks: {tasks_str}\n"
410
+ f"Attempts: {attempts_str}\n"
411
+ f"Environment: {env_str}\n"
412
+ f"Opik: {'enabled' if with_opik else 'disabled'}\n"
413
+ f"Assessment: {eval_str}",
414
+ title="nasde",
415
+ )
416
+ )
417
+
418
+
419
+ def _confirm_multi_variant_run(
420
+ variants: list[str],
421
+ config: ProjectConfig,
422
+ tasks_filter: list[str] | None,
423
+ attempts: int,
424
+ ) -> None:
425
+ from rich.table import Table
426
+
427
+ from nasde_toolkit.banner import print_banner
428
+
429
+ print_banner(console)
430
+
431
+ task_names = [t.name for t in config.tasks]
432
+ if tasks_filter:
433
+ task_names = [n for n in task_names if n in set(tasks_filter)]
434
+
435
+ n_variants = len(variants)
436
+ n_tasks = len(task_names)
437
+ total_trials = n_variants * n_tasks * attempts
438
+
439
+ table = Table(title="Multi-Variant Run")
440
+ table.add_column("Variants", style="cyan")
441
+ table.add_column("Tasks", style="green")
442
+ table.add_column("Attempts", justify="right")
443
+ table.add_column("Total trials", justify="right", style="bold")
444
+ table.add_row(
445
+ ", ".join(variants),
446
+ ", ".join(task_names) if task_names else "(none)",
447
+ str(attempts),
448
+ str(total_trials),
449
+ )
450
+ console.print(table)
451
+
452
+ typer.confirm(
453
+ f"Run {total_trials} trials ({n_variants} variants x {n_tasks} tasks x {attempts} attempts)?",
454
+ abort=True,
455
+ )
456
+
457
+
458
+ async def _run_all_variants(
459
+ config: ProjectConfig,
460
+ variants: list[str],
461
+ model: str | None,
462
+ timeout_sec: int | None,
463
+ tasks_filter: list[str] | None,
464
+ with_opik: bool,
465
+ with_eval: bool,
466
+ harbor_env: str | None,
467
+ n_attempts: int,
468
+ job_suffix: str | None = None,
469
+ max_concurrent_eval: int = 10,
470
+ ) -> None:
471
+
472
+ from nasde_toolkit.runner import run_benchmark
473
+
474
+ results: list[tuple[str, str, str]] = []
475
+
476
+ for i, variant_name in enumerate(variants, 1):
477
+ console.print(f"\n[bold]=== Variant {i}/{len(variants)}: {variant_name} ===[/bold]\n")
478
+ try:
479
+ await run_benchmark(
480
+ config=config,
481
+ variant=variant_name,
482
+ model=model,
483
+ timeout_sec=timeout_sec,
484
+ tasks_filter=tasks_filter,
485
+ with_opik=with_opik,
486
+ with_eval=with_eval,
487
+ harbor_env=harbor_env,
488
+ n_attempts=n_attempts,
489
+ job_suffix=job_suffix,
490
+ max_concurrent_eval=max_concurrent_eval,
491
+ )
492
+ results.append((variant_name, "[green]OK[/green]", ""))
493
+ except SystemExit:
494
+ results.append((variant_name, "[red]FAILED[/red]", "system exit"))
495
+ except Exception as exc:
496
+ console.print(f"[red]ERROR running variant '{variant_name}': {exc}[/red]")
497
+ results.append((variant_name, "[red]FAILED[/red]", str(exc)))
498
+
499
+ _print_multi_variant_summary(results)
500
+
501
+
502
+ def _print_multi_variant_summary(
503
+ results: list[tuple[str, str, str]],
504
+ ) -> None:
505
+ from rich.table import Table
506
+
507
+ table = Table(title="Multi-Variant Summary")
508
+ table.add_column("Variant", style="cyan")
509
+ table.add_column("Status")
510
+ table.add_column("Error")
511
+ for variant_name, status, error in results:
512
+ table.add_row(variant_name, status, error)
513
+ console.print(table)