nabla-cli 0.2.1__tar.gz → 0.4.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. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/PKG-INFO +2 -1
  2. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/__init__.py +1 -1
  3. nabla_cli-0.4.0/nabla/branding.py +56 -0
  4. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/cli.py +128 -43
  5. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/recorder.py +13 -5
  6. nabla_cli-0.4.0/nabla/ui.py +170 -0
  7. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla_cli.egg-info/PKG-INFO +2 -1
  8. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla_cli.egg-info/SOURCES.txt +2 -0
  9. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla_cli.egg-info/requires.txt +1 -0
  10. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/pyproject.toml +2 -1
  11. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_cli_ux.py +2 -2
  12. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/README.md +0 -0
  13. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/__main__.py +0 -0
  14. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/config.py +0 -0
  15. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/contract.py +0 -0
  16. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/induce.py +0 -0
  17. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/profile.py +0 -0
  18. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/prompt_recon.py +0 -0
  19. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/samplegen.py +0 -0
  20. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/scanner.py +0 -0
  21. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/schema_resolver.py +0 -0
  22. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/schemas/site_profile.schema.json +0 -0
  23. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/term.py +0 -0
  24. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla_cli.egg-info/dependency_links.txt +0 -0
  25. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla_cli.egg-info/entry_points.txt +0 -0
  26. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla_cli.egg-info/top_level.txt +0 -0
  27. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/setup.cfg +0 -0
  28. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_config.py +0 -0
  29. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_e2e_profile.py +0 -0
  30. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_induce.py +0 -0
  31. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_prompt_recon.py +0 -0
  32. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_recorder.py +0 -0
  33. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_samplegen.py +0 -0
  34. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_scanner.py +0 -0
  35. {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_schema_resolver.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nabla-cli
3
- Version: 0.2.1
3
+ Version: 0.4.0
4
4
  Summary: Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -8,6 +8,7 @@ Description-Content-Type: text/markdown
8
8
  Requires-Dist: jsonschema
9
9
  Requires-Dist: pydantic>=2
10
10
  Requires-Dist: openai>=1.40
11
+ Requires-Dist: rich>=13.0
11
12
  Provides-Extra: dev
12
13
  Requires-Dist: pytest; extra == "dev"
13
14
 
@@ -1,3 +1,3 @@
1
1
  """nabla — call-site harvester CLI (pre-Phase-0)."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.4.0"
@@ -0,0 +1,56 @@
1
+ """nabla terminal branding: the ∇ logo and startup banner.
2
+
3
+ The banner prints only on a real terminal — piped output, tests, and
4
+ --json consumers never see it. Block glyphs render via the Windows
5
+ console's UTF-16 writer, so no legacy-codepage fallback is needed on an
6
+ interactive console.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from rich.console import Console
12
+ from rich.text import Text
13
+
14
+ from . import __version__
15
+
16
+ # Proportioned after the wordmark: tall triangle, heavy top bar,
17
+ # strokes converging to a centered apex.
18
+ _LOGO = [
19
+ "▛▀▀▀▀▀▀▀▀▀▜",
20
+ "▚ ▞",
21
+ " ▚ ▞",
22
+ " ▚ ▞",
23
+ " ▚ ▞",
24
+ " ▚ ▞",
25
+ " ▀",
26
+ ]
27
+
28
+ _BRAND_STYLE = "bold cyan"
29
+
30
+
31
+ def banner_lines() -> list[tuple[str, str]]:
32
+ """(logo_line, tagline) pairs; taglines are empty on the outer rows."""
33
+ taglines = [
34
+ "",
35
+ "",
36
+ "nabla",
37
+ f"v{__version__} · OpenAI call-site harvester",
38
+ "scan · record · profile",
39
+ "",
40
+ "",
41
+ ]
42
+ return list(zip(_LOGO, taglines))
43
+
44
+
45
+ def print_banner(console: Console | None = None) -> None:
46
+ console = console or Console()
47
+ if not console.is_terminal:
48
+ return
49
+ console.print()
50
+ for logo_line, tagline in banner_lines():
51
+ row = Text()
52
+ row.append(f" {logo_line:<14}", style=_BRAND_STYLE)
53
+ if tagline:
54
+ row.append(tagline, style="bold" if tagline == "nabla" else "dim")
55
+ console.print(row)
56
+ console.print()
@@ -9,13 +9,28 @@ import sys
9
9
  from getpass import getpass
10
10
  from pathlib import Path
11
11
 
12
+ from rich.console import Console
13
+
14
+ from . import __version__
15
+ from .branding import print_banner
12
16
  from .config import PROVIDERS, apply_to_env, save_config
13
- from .contract import Example, is_supported
17
+ from .contract import Example, Verifiability, is_supported
14
18
  from .profile import build_profile, find_site
15
19
  from .recorder import RecordSource, ZeroTrafficError, record
16
20
  from .scanner import scan
17
21
  from .schema_resolver import resolve_schema
18
- from .term import init_terminal, style
22
+
23
+
24
+ def _make_console(stderr: bool = False) -> Console:
25
+ console = Console(stderr=stderr, highlight=False)
26
+ if not console.is_terminal:
27
+ # Wide fixed width so captured/piped output never wraps or truncates.
28
+ console = Console(stderr=stderr, highlight=False, width=200)
29
+ return console
30
+
31
+
32
+ _out = _make_console()
33
+ _errcon = _make_console(stderr=True)
19
34
 
20
35
  _PROVIDER_DESCRIPTIONS = {
21
36
  "openai": "real OpenAI API, uses your OPENAI_API_KEY",
@@ -37,6 +52,7 @@ def _build_parser() -> argparse.ArgumentParser:
37
52
  " nabla profile write the site profile\n"
38
53
  ),
39
54
  )
55
+ parser.add_argument("--version", action="version", version=f"nabla {__version__}")
40
56
  sub = parser.add_subparsers(dest="command", required=True)
41
57
 
42
58
  p_init = sub.add_parser(
@@ -116,15 +132,19 @@ def _build_parser() -> argparse.ArgumentParser:
116
132
 
117
133
 
118
134
  def _ok(msg: str) -> None:
119
- print(style(msg, "green"))
135
+ _out.print(f"✓ {msg}" if _out.is_terminal else msg, style="green", markup=False)
120
136
 
121
137
 
122
138
  def _warn(msg: str) -> None:
123
- print(style(msg, "yellow", stream=sys.stderr), file=sys.stderr)
139
+ _errcon.print(msg, style="yellow", markup=False)
124
140
 
125
141
 
126
142
  def _fail(msg: str) -> None:
127
- print(style(msg, "red", stream=sys.stderr), file=sys.stderr)
143
+ _errcon.print(msg, style="red", markup=False)
144
+
145
+
146
+ def _next_step(cmd: str) -> None:
147
+ _out.print(f"→ next: {cmd}" if _out.is_terminal else f"next: {cmd}", style="cyan", markup=False)
128
148
 
129
149
 
130
150
  def _unconfigured() -> bool:
@@ -140,7 +160,6 @@ def _unconfigured() -> bool:
140
160
 
141
161
 
142
162
  def main(argv: list[str] | None = None) -> int:
143
- init_terminal()
144
163
  # LLM-produced text (goal descriptions, error bodies) can contain
145
164
  # characters legacy Windows consoles (cp1252) can't encode; degrade to
146
165
  # "?" instead of crashing mid-command.
@@ -158,6 +177,7 @@ def main(argv: list[str] | None = None) -> int:
158
177
  parser = _build_parser()
159
178
  argv = sys.argv[1:] if argv is None else argv
160
179
  if not argv:
180
+ print_banner(_out)
161
181
  parser.print_help()
162
182
  return 0
163
183
 
@@ -200,23 +220,24 @@ def _read_api_key(prompt: str) -> str:
200
220
 
201
221
 
202
222
  def _cmd_init(args) -> int:
223
+ from .ui import RAIL, rail, rail_end, rail_ok, rail_start, select
224
+
225
+ print_banner(_out)
226
+ rail_start(_out, "nabla init")
227
+ rail(_out)
228
+
203
229
  provider = args.provider
204
230
  if provider is None:
205
231
  names = list(PROVIDERS)
206
- print(style("Pick a provider:", "bold"))
207
- for i, name in enumerate(names, start=1):
208
- print(f" {style(f'{i})', 'cyan')} {style(name, 'bold')} — "
209
- f"{_PROVIDER_DESCRIPTIONS.get(name, '')}")
210
- choice = input("> ").strip()
211
- if choice.isdigit() and 1 <= int(choice) <= len(names):
212
- provider = names[int(choice) - 1]
213
- else:
214
- provider = choice
232
+ options = [(name, _PROVIDER_DESCRIPTIONS.get(name, "")) for name in names]
233
+ provider = names[select(_out, "Provider", options)]
234
+ else:
235
+ rail(_out, f"Provider {provider}")
215
236
 
216
237
  api_key = args.api_key
217
238
  if api_key is None:
218
239
  key_env = PROVIDERS.get(provider, {}).get("api_key_env", "API key")
219
- api_key = _read_api_key(f"Paste your {key_env}: ")
240
+ api_key = _read_api_key(f"{RAIL} {key_env} ")
220
241
 
221
242
  if not api_key:
222
243
  _fail("nabla init: no API key given")
@@ -229,12 +250,14 @@ def _cmd_init(args) -> int:
229
250
  return 2
230
251
 
231
252
  masked = f"...{api_key[-4:]}" if len(api_key) >= 4 else "..."
232
- _ok(f"nabla init: saved to {path} (key: {masked})")
253
+ rail(_out)
254
+ rail_ok(_out, f"saved to {path} (key: {masked})")
233
255
  try:
234
256
  _ensure_scripts_on_path()
235
257
  except Exception:
236
258
  pass # PATH help is best-effort; setup itself already succeeded
237
- print(style("Setup complete — try: nabla scan", "cyan"))
259
+ rail(_out)
260
+ rail_end(_out, "next: nabla scan — run it from inside the repo you want to harvest")
238
261
  return 0
239
262
 
240
263
 
@@ -300,23 +323,42 @@ def _cmd_scan(args) -> int:
300
323
  if args.json:
301
324
  print(json.dumps(rows, indent=2))
302
325
  return 0
303
- print(style(f"{'SYMBOL':<22} {'FILE':<28} {'KIND':<6} {'VERIFIABILITY':<22} FLAGS", "bold"))
326
+
327
+ from rich import box as rich_box
328
+ from rich.table import Table
329
+ from rich.text import Text
330
+
331
+ table = Table(box=rich_box.SIMPLE_HEAD, header_style="bold", pad_edge=False)
332
+ for col in ("SYMBOL", "FILE", "KIND", "VERIFIABILITY", "HARVESTABLE", "FLAGS"):
333
+ table.add_column(col, overflow="fold")
304
334
  supported_count = 0
305
- for r in rows:
335
+ # Harvestable sites first — the reader's question is "what can I use?"
336
+ keyed = sorted(rows, key=lambda r: not is_supported(Verifiability(r["verifiability"]), r["flags"]))
337
+ for r in keyed:
306
338
  verif = r["verifiability"]
339
+ harvestable = is_supported(Verifiability(verif), r["flags"])
340
+ if harvestable:
341
+ supported_count += 1
307
342
  if verif in ("json_schema", "pydantic_parse"):
308
343
  verif_color = "green"
309
- if not r["flags"]:
310
- supported_count += 1
311
344
  elif verif in ("tool_call", "json_object_no_schema"):
312
345
  verif_color = "yellow"
313
346
  else:
314
347
  verif_color = "dim"
315
- flags = ",".join(r["flags"]) or "-"
316
- print(f"{style(r['symbol'], 'cyan')}{' ' * max(1, 22 - len(r['symbol']))}"
317
- f"{r['file']:<28} {r['kind']:<6} "
318
- f"{style(f'{verif:<22}', verif_color)} {flags}")
319
- print(f"\n{len(rows)} call sites found.")
348
+ table.add_row(
349
+ Text(r["symbol"], style="cyan" if harvestable else "dim"),
350
+ r["file"],
351
+ r["kind"],
352
+ Text(verif, style=verif_color),
353
+ Text("yes", style="green") if harvestable else Text("no", style="dim"),
354
+ ",".join(r["flags"]) or "-",
355
+ style=None if harvestable else "dim",
356
+ )
357
+ _out.print(table)
358
+ noun = "call site" if len(rows) == 1 else "call sites"
359
+ _out.print(f"{len(rows)} {noun} found — {supported_count} harvestable.", markup=False)
360
+ if supported_count:
361
+ _next_step("nabla record" + (f" {args.repo}" if args.repo != "." else ""))
320
362
  if _unconfigured():
321
363
  _warn("tip: run `nabla init` once to set up a provider — `nabla record` "
322
364
  "and `nabla profile` need one")
@@ -352,7 +394,8 @@ def _resolve_site(args, sites):
352
394
 
353
395
  if len(supported) == 1:
354
396
  site = supported[0]
355
- print(style(f"nabla: using site {site.symbol} (the only supported call site)", "dim"))
397
+ from .ui import rail
398
+ rail(_out, f"using site {site.symbol} (the only supported call site)", style="dim")
356
399
  return site, None
357
400
 
358
401
  candidates = supported or sites
@@ -372,10 +415,15 @@ def _resolve_site(args, sites):
372
415
 
373
416
 
374
417
  def _cmd_record(args) -> int:
418
+ from .ui import make_progress, rail, rail_end, rail_ok, rail_start
419
+
420
+ rail_start(_out, "nabla record")
375
421
  sites = scan(args.repo)
376
422
  site, err = _resolve_site(args, sites)
377
423
  if err is not None:
378
424
  return err
425
+ if args.site:
426
+ rail(_out, f"site {site.symbol} · {site.file}", style="dim")
379
427
 
380
428
  if _unconfigured():
381
429
  _warn(
@@ -394,7 +442,8 @@ def _cmd_record(args) -> int:
394
442
  else:
395
443
  conventional = Path(f"{site.symbol}.samples.jsonl")
396
444
  if conventional.exists():
397
- print(f"nabla record: using {conventional} (found by convention; pass --samples to override)")
445
+ rail(_out, f"using {conventional} (found by convention; pass --samples to override)",
446
+ style="dim")
398
447
  source = RecordSource(kind="samples", samples_file=str(conventional))
399
448
  else:
400
449
  source, err = _generate_samples_source(args, site, conventional)
@@ -402,8 +451,23 @@ def _cmd_record(args) -> int:
402
451
  return err
403
452
  source_kind = "generated_samples"
404
453
 
454
+ on_progress = None
455
+ progress = None
456
+ if _out.is_terminal and source.kind == "samples":
457
+ progress = make_progress(_out)
458
+ task_ref: list = []
459
+
460
+ def on_progress(done: int, total: int) -> None:
461
+ if not task_ref:
462
+ task_ref.append(progress.add_task("recording", total=total))
463
+ progress.update(task_ref[0], completed=done)
464
+
405
465
  try:
406
- examples = record(site, source, args.repo)
466
+ if progress is not None:
467
+ with progress:
468
+ examples = record(site, source, args.repo, on_progress=on_progress)
469
+ else:
470
+ examples = record(site, source, args.repo)
407
471
  except ZeroTrafficError as exc:
408
472
  _fail(f"nabla record: {exc}")
409
473
  return 1
@@ -411,11 +475,17 @@ def _cmd_record(args) -> int:
411
475
  with open(out, "w", encoding="utf-8") as f:
412
476
  for ex in examples:
413
477
  f.write(json.dumps({**ex.to_dict(), "_source_kind": source_kind or source.kind}) + "\n")
414
- _ok(f"nabla record: {len(examples)} examples -> {out}")
478
+ rail(_out)
479
+ rail_ok(_out, f"recorded {len(examples)} (prompt, completion) pairs from "
480
+ f"site {site.symbol} -> {out}")
481
+ if source_kind == "generated_samples":
482
+ rail(_out, f"inputs were synthetic — edit {site.symbol}.samples.jsonl with real "
483
+ "data and rerun for a higher-fidelity harvest", style="yellow")
484
+ rail(_out)
415
485
  if args.out is None and args.site is None:
416
- print(style(f"next: nabla profile {args.repo}", "cyan"))
486
+ rail_end(_out, f"next: nabla profile{'' if args.repo == '.' else ' ' + args.repo}")
417
487
  else:
418
- print(style(f"next: nabla profile {args.repo} --site {site.symbol} --examples {out}", "cyan"))
488
+ rail_end(_out, f"next: nabla profile {args.repo} --site {site.symbol} --examples {out}")
419
489
  return 0
420
490
 
421
491
 
@@ -440,8 +510,9 @@ def _generate_samples_source(args, site, out_path: Path):
440
510
  )
441
511
  return None, 1
442
512
  out_path.write_text("\n".join(json.dumps(item) for item in items) + "\n", encoding="utf-8")
443
- print(f"nabla record: no samples given — generated {len(items)} synthetic inputs -> {out_path} "
444
- "(edit this file and rerun to use realistic inputs)")
513
+ from .ui import rail
514
+ rail(_out, f"no samples given generated {len(items)} synthetic inputs -> {out_path} "
515
+ "(edit this file and rerun to use realistic inputs)", style="dim")
445
516
  return RecordSource(kind="samples", samples_file=str(out_path)), None
446
517
 
447
518
 
@@ -449,11 +520,15 @@ def _cmd_profile(args) -> int:
449
520
  from .induce import induce
450
521
  from .prompt_recon import reconstruct
451
522
  from .schema_resolver import resolve_schema as _resolve
523
+ from .ui import rail, rail_end, rail_ok, rail_start
452
524
 
525
+ rail_start(_out, "nabla profile")
453
526
  sites = scan(args.repo)
454
527
  site, err = _resolve_site(args, sites)
455
528
  if err is not None:
456
529
  return err
530
+ if args.site:
531
+ rail(_out, f"site {site.symbol} · {site.file}", style="dim")
457
532
 
458
533
  examples_path = args.examples
459
534
  if examples_path is None:
@@ -470,7 +545,7 @@ def _cmd_profile(args) -> int:
470
545
  print(f"nabla profile: no recorded examples found ({conventional} or recorded.jsonl). "
471
546
  f"Run first: {record_cmd}", file=sys.stderr)
472
547
  return 2
473
- print(f"nabla profile: using {examples_path}")
548
+ rail(_out, f"using {examples_path}", style="dim")
474
549
 
475
550
  examples, recorded_from = [], "samples"
476
551
  for line in Path(examples_path).read_text(encoding="utf-8").splitlines():
@@ -486,19 +561,29 @@ def _cmd_profile(args) -> int:
486
561
 
487
562
  template = reconstruct(site, args.repo)
488
563
  schema, _ = _resolve(site, args.repo)
489
- goal = induce(template, schema, examples)
564
+ if _out.is_terminal:
565
+ with _out.status(f"inferring goal from {len(examples)} examples", spinner="dots"):
566
+ goal = induce(template, schema, examples)
567
+ else:
568
+ goal = induce(template, schema, examples)
490
569
 
491
570
  profile = build_profile(site, args.repo, examples, goal, recorded_from=recorded_from)
492
571
  out = args.out or f"{site.symbol}.profile.json"
493
572
  Path(out).write_text(json.dumps(profile, indent=2), encoding="utf-8")
494
573
  supported = profile["classification"]["supported"]
495
- _ok(f"nabla profile: wrote {out} "
496
- f"(site {profile['site_id'][:12]}…, supported={supported}, "
497
- f"{len(examples)} examples)")
574
+ rail(_out)
575
+ rail_ok(_out, f"wrote {out}")
576
+ rail(_out, f"site {site.symbol} · {profile['site_id'][:16]}…")
577
+ rail(_out, f"supported {supported}")
578
+ rail(_out, f"examples {len(examples)} ({recorded_from})")
579
+ rail(_out, f"goal {goal.description[:90]}")
580
+ dims = ", ".join(d["name"] for d in goal.difficulty_dimensions[:4])
581
+ rail(_out, f"difficulty {dims}")
498
582
  if len(examples) < 20:
499
- _warn(f"nabla profile: WARNING — {len(examples)} examples < 20; "
500
- "Phase-0-ready profiles need >=20 (cli-plan §5.4)")
501
- print(f"goal: {goal.description[:100]}")
583
+ rail(_out, f"only {len(examples)} examples (< 20) — enough for a look, but a "
584
+ "handoff-quality profile wants 20+ real-traffic pairs", style="yellow")
585
+ rail(_out)
586
+ rail_end(_out, f"done — {out} is the handoff artifact", style="green")
502
587
  return 0
503
588
 
504
589
 
@@ -421,7 +421,8 @@ def _run_site_in_subprocess(site: RawSite, repo_path: str, input_slots: dict, en
421
421
  )
422
422
 
423
423
 
424
- def _record_samples(site: RawSite, source: RecordSource, repo_path: str) -> list[Example]:
424
+ def _record_samples(site: RawSite, source: RecordSource, repo_path: str,
425
+ on_progress=None) -> list[Example]:
425
426
  if not source.samples_file:
426
427
  raise ValueError("RecordSource.kind='samples' requires samples_file")
427
428
  sample_lines = _read_jsonl(source.samples_file)
@@ -466,8 +467,11 @@ def _record_samples(site: RawSite, source: RecordSource, repo_path: str) -> list
466
467
  new_captures = list(proxy.captured[before:])
467
468
  for capture in new_captures:
468
469
  examples.append(_capture_to_example(capture, input_slots))
469
- print(f"nabla record: sample {i + 1}/{total} -> {len(new_captures)} capture(s)",
470
- file=sys.stderr)
470
+ if on_progress is not None:
471
+ on_progress(i + 1, total)
472
+ else:
473
+ print(f"nabla record: sample {i + 1}/{total} -> {len(new_captures)} capture(s)",
474
+ file=sys.stderr)
471
475
  finally:
472
476
  proxy.stop()
473
477
 
@@ -544,15 +548,19 @@ def _record_logs(site: RawSite, source: RecordSource, repo_path: str) -> list[Ex
544
548
  # Public interface
545
549
  # ---------------------------------------------------------------------------
546
550
 
547
- def record(site: RawSite, source: RecordSource, repo_path: str) -> list[Example]:
551
+ def record(site: RawSite, source: RecordSource, repo_path: str,
552
+ on_progress=None) -> list[Example]:
548
553
  """Capture real (prompt, completion, tokens, latency) pairs for site.
549
554
 
550
555
  Must: attribute captured requests back to the site (template match),
551
556
  populate token counts and latency, raise ZeroTrafficError on an empty
552
557
  proxy run instead of returning [].
558
+
559
+ ``on_progress(done, total)`` (samples path only) replaces the default
560
+ per-sample stderr lines — used by the CLI to drive a progress bar.
553
561
  """
554
562
  if source.kind == "samples":
555
- return _record_samples(site, source, repo_path)
563
+ return _record_samples(site, source, repo_path, on_progress=on_progress)
556
564
  if source.kind == "proxy":
557
565
  return _record_proxy(site, source, repo_path)
558
566
  if source.kind == "logs":
@@ -0,0 +1,170 @@
1
+ """nabla's terminal design system.
2
+
3
+ One visual language across commands:
4
+
5
+ - a rail (``┌ │ └``) threads each multi-step flow so the user always sees
6
+ where they are in the sequence — structure as information, not decoration;
7
+ - selection is pointer-driven (arrow keys) on an interactive Windows
8
+ console, with a numbered fallback everywhere else;
9
+ - progress fills with the nabla glyph itself — the one flourish;
10
+ - color carries meaning only: cyan = brand/interactive, green = done,
11
+ yellow = caution, red = failure, dim = structure.
12
+
13
+ Glyphs are box-drawing / geometric only — never emoji.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+
20
+ from rich.console import Console
21
+ from rich.progress import Progress, ProgressColumn, TextColumn
22
+ from rich.text import Text
23
+
24
+ RAIL = "│"
25
+ _POINTER = "❯"
26
+ _ON = "●"
27
+ _OFF = "○"
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Rail primitives
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def rail_start(console: Console, title: str) -> None:
35
+ line = Text("┌ ", style="dim")
36
+ line.append(title, style="bold")
37
+ console.print(line)
38
+
39
+
40
+ def rail(console: Console, text: str = "", style: str | None = None) -> None:
41
+ line = Text(RAIL, style="dim")
42
+ if text:
43
+ line.append(" " + text, style=style or "")
44
+ console.print(line)
45
+
46
+
47
+ def rail_ok(console: Console, text: str) -> None:
48
+ line = Text(f"{RAIL} ", style="dim")
49
+ line.append("✓ ", style="green")
50
+ line.append(text)
51
+ console.print(line)
52
+
53
+
54
+ def rail_end(console: Console, text: str, style: str = "cyan") -> None:
55
+ line = Text("└ ", style="dim")
56
+ line.append(text, style=style)
57
+ console.print(line)
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Selection
62
+ # ---------------------------------------------------------------------------
63
+
64
+ def select(console: Console, label: str, options: list[tuple[str, str]],
65
+ default: int = 0) -> int:
66
+ """Pick one of ``options`` ([(name, description), ...]); returns the index.
67
+
68
+ Interactive Windows console: arrow keys + enter, pointer rendering,
69
+ menu collapses to a single confirmation line after the choice.
70
+ Anywhere else: numbered prompt.
71
+ """
72
+ interactive = (
73
+ sys.platform == "win32"
74
+ and console.is_terminal
75
+ and sys.stdin.isatty()
76
+ )
77
+ if not interactive:
78
+ rail(console, label, style="bold")
79
+ for i, (name, desc) in enumerate(options, start=1):
80
+ rail(console, f" {i}) {name} — {desc}")
81
+ choice = input(f"{RAIL} > ").strip()
82
+ if choice.isdigit() and 1 <= int(choice) <= len(options):
83
+ return int(choice) - 1
84
+ for i, (name, _) in enumerate(options):
85
+ if name == choice:
86
+ return i
87
+ return default
88
+
89
+ import msvcrt
90
+
91
+ from .term import init_terminal
92
+
93
+ init_terminal()
94
+ idx = default
95
+ name_width = max(len(name) for name, _ in options)
96
+ hint = "↑ ↓ move · enter chooses"
97
+ lines_drawn = 0
98
+
99
+ def draw() -> None:
100
+ nonlocal lines_drawn
101
+ if lines_drawn:
102
+ sys.stdout.write(f"\x1b[{lines_drawn}A\x1b[0J")
103
+ header = Text(f"{RAIL} ", style="dim")
104
+ header.append(label, style="bold")
105
+ header.append(f" {hint}", style="dim")
106
+ console.print(header)
107
+ for i, (name, desc) in enumerate(options):
108
+ row = Text(f"{RAIL} ", style="dim")
109
+ if i == idx:
110
+ row.append(f" {_POINTER} {_ON} ", style="cyan")
111
+ row.append(f"{name:<{name_width}}", style="bold cyan")
112
+ else:
113
+ row.append(f" {_OFF} ", style="dim")
114
+ row.append(f"{name:<{name_width}}", style="dim")
115
+ row.append(f" {desc}", style="dim")
116
+ console.print(row)
117
+ lines_drawn = len(options) + 1
118
+
119
+ draw()
120
+ while True:
121
+ ch = msvcrt.getwch()
122
+ if ch in ("\r", "\n"):
123
+ break
124
+ if ch == "\x03":
125
+ raise KeyboardInterrupt
126
+ if ch in ("\xe0", "\x00"):
127
+ arrow = msvcrt.getwch()
128
+ if arrow == "H":
129
+ idx = (idx - 1) % len(options)
130
+ elif arrow == "P":
131
+ idx = (idx + 1) % len(options)
132
+ draw()
133
+
134
+ # Collapse the menu into one confirmation line.
135
+ sys.stdout.write(f"\x1b[{lines_drawn}A\x1b[0J")
136
+ chosen = Text(f"{RAIL} ", style="dim")
137
+ chosen.append(f"{label} ", style="bold")
138
+ chosen.append(options[idx][0], style="cyan")
139
+ console.print(chosen)
140
+ return idx
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Progress: the ∇ bar
145
+ # ---------------------------------------------------------------------------
146
+
147
+ class NablaBar(ProgressColumn):
148
+ """A bar that fills with the nabla glyph — brand as instrument."""
149
+
150
+ def __init__(self, width: int = 28):
151
+ super().__init__()
152
+ self.width = width
153
+
154
+ def render(self, task) -> Text:
155
+ total = task.total or 1
156
+ filled = int(self.width * min(task.completed / total, 1.0))
157
+ bar = Text()
158
+ bar.append("∇" * filled, style="cyan")
159
+ bar.append("·" * (self.width - filled), style="dim")
160
+ return bar
161
+
162
+
163
+ def make_progress(console: Console, unit: str = "samples") -> Progress:
164
+ return Progress(
165
+ TextColumn(RAIL, style="dim"),
166
+ NablaBar(),
167
+ TextColumn(f"{{task.completed}}/{{task.total}} {unit}", style="dim"),
168
+ console=console,
169
+ transient=True,
170
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nabla-cli
3
- Version: 0.2.1
3
+ Version: 0.4.0
4
4
  Summary: Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -8,6 +8,7 @@ Description-Content-Type: text/markdown
8
8
  Requires-Dist: jsonschema
9
9
  Requires-Dist: pydantic>=2
10
10
  Requires-Dist: openai>=1.40
11
+ Requires-Dist: rich>=13.0
11
12
  Provides-Extra: dev
12
13
  Requires-Dist: pytest; extra == "dev"
13
14
 
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  nabla/__init__.py
4
4
  nabla/__main__.py
5
+ nabla/branding.py
5
6
  nabla/cli.py
6
7
  nabla/config.py
7
8
  nabla/contract.py
@@ -13,6 +14,7 @@ nabla/samplegen.py
13
14
  nabla/scanner.py
14
15
  nabla/schema_resolver.py
15
16
  nabla/term.py
17
+ nabla/ui.py
16
18
  nabla/schemas/site_profile.schema.json
17
19
  nabla_cli.egg-info/PKG-INFO
18
20
  nabla_cli.egg-info/SOURCES.txt
@@ -1,6 +1,7 @@
1
1
  jsonschema
2
2
  pydantic>=2
3
3
  openai>=1.40
4
+ rich>=13.0
4
5
 
5
6
  [dev]
6
7
  pytest
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nabla-cli"
3
- version = "0.2.1"
3
+ version = "0.4.0"
4
4
  description = "Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -9,6 +9,7 @@ dependencies = [
9
9
  "jsonschema",
10
10
  "pydantic>=2",
11
11
  "openai>=1.40",
12
+ "rich>=13.0",
12
13
  ]
13
14
 
14
15
  [project.optional-dependencies]
@@ -80,7 +80,7 @@ def test_init_writes_config(tmp_path, monkeypatch, capsys):
80
80
  out = capsys.readouterr().out
81
81
  assert "gsk_test" not in out # the full key must never be echoed
82
82
  assert "test" in out # masked last-4 tail is shown
83
- assert "Setup complete — try: nabla scan" in out
83
+ assert "next: nabla scan" in out
84
84
 
85
85
 
86
86
  def test_init_bad_provider_exits_nonzero_with_helpful_message(capsys):
@@ -282,7 +282,7 @@ def test_profile_finds_recorded_file_by_convention(tmp_path, monkeypatch, capsys
282
282
  assert (tmp_path / "extract_receipt.profile.json").exists()
283
283
  stdout = capsys.readouterr().out
284
284
  assert "using extract_receipt.recorded.jsonl" in stdout
285
- assert "goal: Extracts receipt fields." in stdout
285
+ assert "Extracts receipt fields." in stdout
286
286
 
287
287
 
288
288
  def test_profile_without_recorded_file_exits_2_with_record_command(tmp_path, monkeypatch, capsys):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes