nabla-cli 0.3.0__tar.gz → 0.4.1__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 (36) hide show
  1. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/PKG-INFO +1 -1
  2. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/__init__.py +1 -1
  3. nabla_cli-0.4.1/nabla/branding.py +74 -0
  4. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/cli.py +120 -42
  5. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/recorder.py +18 -6
  6. nabla_cli-0.4.1/nabla/ui.py +171 -0
  7. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/PKG-INFO +1 -1
  8. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/SOURCES.txt +1 -0
  9. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/pyproject.toml +1 -1
  10. nabla_cli-0.3.0/nabla/branding.py +0 -50
  11. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/README.md +0 -0
  12. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/__main__.py +0 -0
  13. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/config.py +0 -0
  14. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/contract.py +0 -0
  15. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/induce.py +0 -0
  16. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/profile.py +0 -0
  17. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/prompt_recon.py +0 -0
  18. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/samplegen.py +0 -0
  19. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/scanner.py +0 -0
  20. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/schema_resolver.py +0 -0
  21. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/schemas/site_profile.schema.json +0 -0
  22. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/term.py +0 -0
  23. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/dependency_links.txt +0 -0
  24. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/entry_points.txt +0 -0
  25. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/requires.txt +0 -0
  26. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/top_level.txt +0 -0
  27. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/setup.cfg +0 -0
  28. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_cli_ux.py +0 -0
  29. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_config.py +0 -0
  30. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_e2e_profile.py +0 -0
  31. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_induce.py +0 -0
  32. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_prompt_recon.py +0 -0
  33. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_recorder.py +0 -0
  34. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_samplegen.py +0 -0
  35. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_scanner.py +0 -0
  36. {nabla_cli-0.3.0 → nabla_cli-0.4.1}/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.3.0
3
+ Version: 0.4.1
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
@@ -1,3 +1,3 @@
1
1
  """nabla — call-site harvester CLI (pre-Phase-0)."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.1"
@@ -0,0 +1,74 @@
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
+ # Uniform thick strokes like the wordmark: solid bar, solid diagonals,
17
+ # single-block apex. Terminal cells are ~2:1 tall, so 6 rows x 11 cols
18
+ # reads close to the mark's near-equilateral proportions.
19
+ _LOGO = [
20
+ "█▀▀▀▀▀▀▀▀▀█",
21
+ " █ █",
22
+ " █ █",
23
+ " █ █",
24
+ " █ █",
25
+ " █",
26
+ ]
27
+
28
+ _BRAND_STYLE = "bold cyan"
29
+
30
+
31
+ def banner_lines() -> list[tuple[str, str, str]]:
32
+ """(logo_line, tagline, tagline_style) triples; outer rows have no tagline."""
33
+ taglines = [
34
+ ("", ""),
35
+ ("nabla", "bold"),
36
+ (f"v{__version__} · OpenAI call-site harvester", ""),
37
+ ("scan · record · profile", "cyan"),
38
+ ("", ""),
39
+ ("", ""),
40
+ ]
41
+ return [(logo, text, style) for logo, (text, style) in zip(_LOGO, taglines)]
42
+
43
+
44
+ def _compose(reveal: int | None = None, with_taglines: bool = True) -> Text:
45
+ frame = Text()
46
+ for logo_line, tagline, tag_style in banner_lines():
47
+ segment = logo_line if reveal is None else logo_line[:reveal]
48
+ frame.append(f" {segment:<14}", style=_BRAND_STYLE)
49
+ if with_taglines and tagline:
50
+ frame.append(tagline, style=tag_style)
51
+ frame.append("\n")
52
+ return frame
53
+
54
+
55
+ def print_banner(console: Console | None = None, animate: bool = False) -> None:
56
+ console = console or Console()
57
+ if not console.is_terminal:
58
+ return
59
+ console.print()
60
+ if animate:
61
+ import time
62
+
63
+ from rich.live import Live
64
+
65
+ width = max(len(line) for line, _, _ in banner_lines())
66
+ with Live(console=console, refresh_per_second=60, transient=False) as live:
67
+ # The mark draws itself in, left to right; the words arrive last.
68
+ for cut in range(width + 1):
69
+ live.update(_compose(reveal=cut, with_taglines=False))
70
+ time.sleep(0.028)
71
+ live.update(_compose())
72
+ else:
73
+ console.print(_compose(), end="")
74
+ console.print()
@@ -191,15 +191,41 @@ def main(argv: list[str] | None = None) -> int:
191
191
  return commands[args.command](args)
192
192
 
193
193
 
194
+ def _clipboard_text() -> str:
195
+ """Windows clipboard contents (CF_UNICODETEXT), or "" on any failure."""
196
+ try:
197
+ import ctypes
198
+
199
+ CF_UNICODETEXT = 13
200
+ user32, kernel32 = ctypes.windll.user32, ctypes.windll.kernel32
201
+ if not user32.OpenClipboard(0):
202
+ return ""
203
+ try:
204
+ handle = user32.GetClipboardData(CF_UNICODETEXT)
205
+ if not handle:
206
+ return ""
207
+ locked = kernel32.GlobalLock(handle)
208
+ if not locked:
209
+ return ""
210
+ try:
211
+ return ctypes.c_wchar_p(locked).value or ""
212
+ finally:
213
+ kernel32.GlobalUnlock(handle)
214
+ finally:
215
+ user32.CloseClipboard()
216
+ except Exception:
217
+ return ""
218
+
219
+
194
220
  def _read_api_key(prompt: str) -> str:
195
221
  """Read a secret with visible masking: one `*` per character, backspace
196
- supported, pasting shows a `*` per pasted character so the user can
197
- see that their input (typed or pasted) actually arrived. Falls back to
198
- fully hidden input where character-level reading isn't available."""
222
+ supported, and Ctrl+V pastes from the clipboard (classic consoles don't
223
+ translate Ctrl+V into keystrokes, which silently swallowed pastes).
224
+ Falls back to fully hidden input where character reading isn't available."""
199
225
  if sys.platform == "win32" and sys.stdin.isatty():
200
226
  import msvcrt
201
227
 
202
- print(prompt, end="", flush=True)
228
+ print(f"{prompt}(paste with Ctrl+V or right-click) ", end="", flush=True)
203
229
  chars: list[str] = []
204
230
  while True:
205
231
  ch = msvcrt.getwch()
@@ -208,7 +234,13 @@ def _read_api_key(prompt: str) -> str:
208
234
  break
209
235
  if ch == "\x03": # Ctrl+C
210
236
  raise KeyboardInterrupt
211
- if ch == "\b":
237
+ if ch == "\x16": # Ctrl+V — classic console gives us the raw control char
238
+ pasted = _clipboard_text().strip()
239
+ for c in pasted:
240
+ if c >= " ":
241
+ chars.append(c)
242
+ print("*" * len(pasted), end="", flush=True)
243
+ elif ch == "\b":
212
244
  if chars:
213
245
  chars.pop()
214
246
  print("\b \b", end="", flush=True)
@@ -220,24 +252,24 @@ def _read_api_key(prompt: str) -> str:
220
252
 
221
253
 
222
254
  def _cmd_init(args) -> int:
223
- print_banner(_out)
255
+ from .ui import RAIL, rail, rail_end, rail_ok, rail_start, select
256
+
257
+ print_banner(_out, animate=True)
258
+ rail_start(_out, "nabla init")
259
+ rail(_out)
260
+
224
261
  provider = args.provider
225
262
  if provider is None:
226
263
  names = list(PROVIDERS)
227
- _out.print("Pick a provider:", style="bold")
228
- for i, name in enumerate(names, start=1):
229
- _out.print(f" [cyan]{i})[/cyan] [bold]{name}[/bold] — "
230
- f"{_PROVIDER_DESCRIPTIONS.get(name, '')}")
231
- choice = input("> ").strip()
232
- if choice.isdigit() and 1 <= int(choice) <= len(names):
233
- provider = names[int(choice) - 1]
234
- else:
235
- provider = choice
264
+ options = [(name, _PROVIDER_DESCRIPTIONS.get(name, "")) for name in names]
265
+ provider = names[select(_out, "Provider", options)]
266
+ else:
267
+ rail(_out, f"Provider {provider}")
236
268
 
237
269
  api_key = args.api_key
238
270
  if api_key is None:
239
271
  key_env = PROVIDERS.get(provider, {}).get("api_key_env", "API key")
240
- api_key = _read_api_key(f"Paste your {key_env}: ")
272
+ api_key = _read_api_key(f"{RAIL} {key_env} ")
241
273
 
242
274
  if not api_key:
243
275
  _fail("nabla init: no API key given")
@@ -250,12 +282,14 @@ def _cmd_init(args) -> int:
250
282
  return 2
251
283
 
252
284
  masked = f"...{api_key[-4:]}" if len(api_key) >= 4 else "..."
253
- _ok(f"nabla init: saved to {path} (key: {masked})")
285
+ rail(_out)
286
+ rail_ok(_out, f"saved to {path} (key: {masked})")
254
287
  try:
255
288
  _ensure_scripts_on_path()
256
289
  except Exception:
257
290
  pass # PATH help is best-effort; setup itself already succeeded
258
- _next_step("nabla scan (run it from inside the repo you want to harvest)")
291
+ rail(_out)
292
+ rail_end(_out, "next: nabla scan — run it from inside the repo you want to harvest")
259
293
  return 0
260
294
 
261
295
 
@@ -330,7 +364,9 @@ def _cmd_scan(args) -> int:
330
364
  for col in ("SYMBOL", "FILE", "KIND", "VERIFIABILITY", "HARVESTABLE", "FLAGS"):
331
365
  table.add_column(col, overflow="fold")
332
366
  supported_count = 0
333
- for r in rows:
367
+ # Harvestable sites first — the reader's question is "what can I use?"
368
+ keyed = sorted(rows, key=lambda r: not is_supported(Verifiability(r["verifiability"]), r["flags"]))
369
+ for r in keyed:
334
370
  verif = r["verifiability"]
335
371
  harvestable = is_supported(Verifiability(verif), r["flags"])
336
372
  if harvestable:
@@ -342,12 +378,13 @@ def _cmd_scan(args) -> int:
342
378
  else:
343
379
  verif_color = "dim"
344
380
  table.add_row(
345
- Text(r["symbol"], style="cyan"),
381
+ Text(r["symbol"], style="cyan" if harvestable else "dim"),
346
382
  r["file"],
347
383
  r["kind"],
348
384
  Text(verif, style=verif_color),
349
385
  Text("yes", style="green") if harvestable else Text("no", style="dim"),
350
386
  ",".join(r["flags"]) or "-",
387
+ style=None if harvestable else "dim",
351
388
  )
352
389
  _out.print(table)
353
390
  noun = "call site" if len(rows) == 1 else "call sites"
@@ -389,8 +426,8 @@ def _resolve_site(args, sites):
389
426
 
390
427
  if len(supported) == 1:
391
428
  site = supported[0]
392
- _out.print(f"nabla: using site {site.symbol} (the only supported call site)",
393
- style="dim", markup=False)
429
+ from .ui import rail
430
+ rail(_out, f"using site {site.symbol} (the only supported call site)")
394
431
  return site, None
395
432
 
396
433
  candidates = supported or sites
@@ -410,10 +447,15 @@ def _resolve_site(args, sites):
410
447
 
411
448
 
412
449
  def _cmd_record(args) -> int:
450
+ from .ui import make_progress, rail, rail_end, rail_ok, rail_start
451
+
452
+ rail_start(_out, "nabla record")
413
453
  sites = scan(args.repo)
414
454
  site, err = _resolve_site(args, sites)
415
455
  if err is not None:
416
456
  return err
457
+ if args.site:
458
+ rail(_out, f"site {site.symbol} · {site.file}", style="dim")
417
459
 
418
460
  if _unconfigured():
419
461
  _warn(
@@ -432,7 +474,7 @@ def _cmd_record(args) -> int:
432
474
  else:
433
475
  conventional = Path(f"{site.symbol}.samples.jsonl")
434
476
  if conventional.exists():
435
- print(f"nabla record: using {conventional} (found by convention; pass --samples to override)")
477
+ rail(_out, f"using {conventional} (found by convention; pass --samples to override)")
436
478
  source = RecordSource(kind="samples", samples_file=str(conventional))
437
479
  else:
438
480
  source, err = _generate_samples_source(args, site, conventional)
@@ -440,24 +482,48 @@ def _cmd_record(args) -> int:
440
482
  return err
441
483
  source_kind = "generated_samples"
442
484
 
485
+ on_progress = None
486
+ progress = None
487
+ if _out.is_terminal and source.kind == "samples":
488
+ progress = make_progress(_out)
489
+ task_ref: list = []
490
+
491
+ def on_progress(done: int, total: int) -> None:
492
+ if not task_ref:
493
+ task_ref.append(progress.add_task("recording", total=total))
494
+ progress.update(task_ref[0], completed=done)
495
+
443
496
  try:
444
- examples = record(site, source, args.repo)
497
+ if progress is not None:
498
+ with progress:
499
+ examples = record(site, source, args.repo, on_progress=on_progress)
500
+ else:
501
+ examples = record(site, source, args.repo)
445
502
  except ZeroTrafficError as exc:
446
503
  _fail(f"nabla record: {exc}")
447
504
  return 1
505
+ except RuntimeError as exc:
506
+ message = str(exc)
507
+ _fail(f"nabla record: {message[:400]}")
508
+ if "invalid_api_key" in message or "Invalid API Key" in message or "401" in message:
509
+ _warn("the configured provider rejected your API key — rerun `nabla init` "
510
+ "and paste it again (the input shows one * per character, including pastes)")
511
+ return 1
448
512
  out = args.out or f"{site.symbol}.recorded.jsonl"
449
513
  with open(out, "w", encoding="utf-8") as f:
450
514
  for ex in examples:
451
515
  f.write(json.dumps({**ex.to_dict(), "_source_kind": source_kind or source.kind}) + "\n")
452
- _ok(f"recorded {len(examples)} (prompt, completion) pairs from "
453
- f"site {site.symbol} -> {out}")
516
+ rail(_out)
517
+ rail_ok(_out, f"recorded {len(examples)} (prompt, completion) pairs from "
518
+ f"site {site.symbol} -> {out}")
454
519
  if source_kind == "generated_samples":
455
- _warn(f"inputs were synthetic — edit {site.symbol}.samples.jsonl with real "
456
- "data and rerun for a higher-fidelity harvest")
520
+ rail(_out, f"inputs were synthetic — edit {site.symbol}.samples.jsonl with real "
521
+ "data and rerun for a higher-fidelity harvest", style="yellow")
522
+ rail(_out)
457
523
  if args.out is None and args.site is None:
458
- _next_step(f"nabla profile{'' if args.repo == '.' else ' ' + args.repo}")
524
+ rail_end(_out, f"next: nabla profile{'' if args.repo == '.' else ' ' + args.repo}")
459
525
  else:
460
- _next_step(f"nabla profile {args.repo} --site {site.symbol} --examples {out}")
526
+ rail_end(_out, f"next: nabla profile {args.repo} --site {site.symbol} --examples {out}")
461
527
  return 0
462
528
 
463
529
 
@@ -482,8 +548,9 @@ def _generate_samples_source(args, site, out_path: Path):
482
548
  )
483
549
  return None, 1
484
550
  out_path.write_text("\n".join(json.dumps(item) for item in items) + "\n", encoding="utf-8")
485
- print(f"nabla record: no samples given — generated {len(items)} synthetic inputs -> {out_path} "
486
- "(edit this file and rerun to use realistic inputs)")
551
+ from .ui import rail
552
+ rail(_out, f"no samples given generated {len(items)} synthetic inputs -> {out_path} "
553
+ "(edit this file and rerun to use realistic inputs)", style="dim")
487
554
  return RecordSource(kind="samples", samples_file=str(out_path)), None
488
555
 
489
556
 
@@ -491,11 +558,15 @@ def _cmd_profile(args) -> int:
491
558
  from .induce import induce
492
559
  from .prompt_recon import reconstruct
493
560
  from .schema_resolver import resolve_schema as _resolve
561
+ from .ui import rail, rail_end, rail_ok, rail_start
494
562
 
563
+ rail_start(_out, "nabla profile")
495
564
  sites = scan(args.repo)
496
565
  site, err = _resolve_site(args, sites)
497
566
  if err is not None:
498
567
  return err
568
+ if args.site:
569
+ rail(_out, f"site {site.symbol} · {site.file}", style="dim")
499
570
 
500
571
  examples_path = args.examples
501
572
  if examples_path is None:
@@ -512,7 +583,7 @@ def _cmd_profile(args) -> int:
512
583
  print(f"nabla profile: no recorded examples found ({conventional} or recorded.jsonl). "
513
584
  f"Run first: {record_cmd}", file=sys.stderr)
514
585
  return 2
515
- print(f"nabla profile: using {examples_path}")
586
+ rail(_out, f"using {examples_path}")
516
587
 
517
588
  examples, recorded_from = [], "samples"
518
589
  for line in Path(examples_path).read_text(encoding="utf-8").splitlines():
@@ -528,22 +599,29 @@ def _cmd_profile(args) -> int:
528
599
 
529
600
  template = reconstruct(site, args.repo)
530
601
  schema, _ = _resolve(site, args.repo)
531
- goal = induce(template, schema, examples)
602
+ if _out.is_terminal:
603
+ with _out.status(f"inferring goal from {len(examples)} examples", spinner="dots"):
604
+ goal = induce(template, schema, examples)
605
+ else:
606
+ goal = induce(template, schema, examples)
532
607
 
533
608
  profile = build_profile(site, args.repo, examples, goal, recorded_from=recorded_from)
534
609
  out = args.out or f"{site.symbol}.profile.json"
535
610
  Path(out).write_text(json.dumps(profile, indent=2), encoding="utf-8")
536
611
  supported = profile["classification"]["supported"]
537
- _ok(f"wrote {out} — the handoff artifact for site {site.symbol}")
538
- _out.print(f" site id {profile['site_id'][:16]}", markup=False)
539
- _out.print(f" supported {supported}", markup=False)
540
- _out.print(f" examples {len(examples)} ({recorded_from})", markup=False)
541
- _out.print(f" goal {goal.description[:90]}", markup=False)
612
+ rail(_out)
613
+ rail_ok(_out, f"wrote {out}")
614
+ rail(_out, f"site {site.symbol} · {profile['site_id'][:16]}…")
615
+ rail(_out, f"supported {supported}")
616
+ rail(_out, f"examples {len(examples)} ({recorded_from})")
617
+ rail(_out, f"goal {goal.description[:90]}")
542
618
  dims = ", ".join(d["name"] for d in goal.difficulty_dimensions[:4])
543
- _out.print(f" difficulty {dims}", markup=False)
619
+ rail(_out, f"difficulty {dims}")
544
620
  if len(examples) < 20:
545
- _warn(f"only {len(examples)} examples (< 20) — enough for a look, "
546
- "but a handoff-quality profile wants 20+ real-traffic pairs")
621
+ rail(_out, f"only {len(examples)} examples (< 20) — enough for a look, but a "
622
+ "handoff-quality profile wants 20+ real-traffic pairs", style="yellow")
623
+ rail(_out)
624
+ rail_end(_out, f"done — {out} is the handoff artifact", style="green")
547
625
  return 0
548
626
 
549
627
 
@@ -415,13 +415,18 @@ def _run_site_in_subprocess(site: RawSite, repo_path: str, input_slots: dict, en
415
415
  timeout = int(os.environ.get("NABLA_SAMPLE_TIMEOUT", "300"))
416
416
  proc = subprocess.run(args, cwd=repo_path, env=env, capture_output=True, text=True, timeout=timeout)
417
417
  if proc.returncode != 0:
418
+ # The last stderr lines carry the actual exception; the rest is
419
+ # traceback scaffolding that buries it.
420
+ lines = [l for l in (proc.stderr or proc.stdout or "").splitlines() if l.strip()]
421
+ detail = " | ".join(lines[-2:]) if lines else "no output"
418
422
  raise RuntimeError(
419
423
  f"Sample invocation of {site.symbol!r} in {site.file!r} failed "
420
- f"(exit {proc.returncode}): {proc.stderr.strip() or proc.stdout.strip()}"
424
+ f"(exit {proc.returncode}): {detail}"
421
425
  )
422
426
 
423
427
 
424
- def _record_samples(site: RawSite, source: RecordSource, repo_path: str) -> list[Example]:
428
+ def _record_samples(site: RawSite, source: RecordSource, repo_path: str,
429
+ on_progress=None) -> list[Example]:
425
430
  if not source.samples_file:
426
431
  raise ValueError("RecordSource.kind='samples' requires samples_file")
427
432
  sample_lines = _read_jsonl(source.samples_file)
@@ -466,8 +471,11 @@ def _record_samples(site: RawSite, source: RecordSource, repo_path: str) -> list
466
471
  new_captures = list(proxy.captured[before:])
467
472
  for capture in new_captures:
468
473
  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)
474
+ if on_progress is not None:
475
+ on_progress(i + 1, total)
476
+ else:
477
+ print(f"nabla record: sample {i + 1}/{total} -> {len(new_captures)} capture(s)",
478
+ file=sys.stderr)
471
479
  finally:
472
480
  proxy.stop()
473
481
 
@@ -544,15 +552,19 @@ def _record_logs(site: RawSite, source: RecordSource, repo_path: str) -> list[Ex
544
552
  # Public interface
545
553
  # ---------------------------------------------------------------------------
546
554
 
547
- def record(site: RawSite, source: RecordSource, repo_path: str) -> list[Example]:
555
+ def record(site: RawSite, source: RecordSource, repo_path: str,
556
+ on_progress=None) -> list[Example]:
548
557
  """Capture real (prompt, completion, tokens, latency) pairs for site.
549
558
 
550
559
  Must: attribute captured requests back to the site (template match),
551
560
  populate token counts and latency, raise ZeroTrafficError on an empty
552
561
  proxy run instead of returning [].
562
+
563
+ ``on_progress(done, total)`` (samples path only) replaces the default
564
+ per-sample stderr lines — used by the CLI to drive a progress bar.
553
565
  """
554
566
  if source.kind == "samples":
555
- return _record_samples(site, source, repo_path)
567
+ return _record_samples(site, source, repo_path, on_progress=on_progress)
556
568
  if source.kind == "proxy":
557
569
  return _record_proxy(site, source, repo_path)
558
570
  if source.kind == "logs":
@@ -0,0 +1,171 @@
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
+ # Unselected is quieter, never grey — grey reads as disabled.
114
+ row.append(f" {_OFF} ", style="dim")
115
+ row.append(f"{name:<{name_width}}")
116
+ row.append(f" {desc}", style="dim")
117
+ console.print(row)
118
+ lines_drawn = len(options) + 1
119
+
120
+ draw()
121
+ while True:
122
+ ch = msvcrt.getwch()
123
+ if ch in ("\r", "\n"):
124
+ break
125
+ if ch == "\x03":
126
+ raise KeyboardInterrupt
127
+ if ch in ("\xe0", "\x00"):
128
+ arrow = msvcrt.getwch()
129
+ if arrow == "H":
130
+ idx = (idx - 1) % len(options)
131
+ elif arrow == "P":
132
+ idx = (idx + 1) % len(options)
133
+ draw()
134
+
135
+ # Collapse the menu into one confirmation line.
136
+ sys.stdout.write(f"\x1b[{lines_drawn}A\x1b[0J")
137
+ chosen = Text(f"{RAIL} ", style="dim")
138
+ chosen.append(f"{label} ", style="bold")
139
+ chosen.append(options[idx][0], style="cyan")
140
+ console.print(chosen)
141
+ return idx
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Progress: the ∇ bar
146
+ # ---------------------------------------------------------------------------
147
+
148
+ class NablaBar(ProgressColumn):
149
+ """A bar that fills with the nabla glyph — brand as instrument."""
150
+
151
+ def __init__(self, width: int = 28):
152
+ super().__init__()
153
+ self.width = width
154
+
155
+ def render(self, task) -> Text:
156
+ total = task.total or 1
157
+ filled = int(self.width * min(task.completed / total, 1.0))
158
+ bar = Text()
159
+ bar.append("∇" * filled, style="cyan")
160
+ bar.append("·" * (self.width - filled), style="dim")
161
+ return bar
162
+
163
+
164
+ def make_progress(console: Console, unit: str = "samples") -> Progress:
165
+ return Progress(
166
+ TextColumn(RAIL, style="dim"),
167
+ NablaBar(),
168
+ TextColumn(f"{{task.completed}}/{{task.total}} {unit}", style="dim"),
169
+ console=console,
170
+ transient=True,
171
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nabla-cli
3
- Version: 0.3.0
3
+ Version: 0.4.1
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
@@ -14,6 +14,7 @@ nabla/samplegen.py
14
14
  nabla/scanner.py
15
15
  nabla/schema_resolver.py
16
16
  nabla/term.py
17
+ nabla/ui.py
17
18
  nabla/schemas/site_profile.schema.json
18
19
  nabla_cli.egg-info/PKG-INFO
19
20
  nabla_cli.egg-info/SOURCES.txt
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nabla-cli"
3
- version = "0.3.0"
3
+ version = "0.4.1"
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" }
@@ -1,50 +0,0 @@
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
- _LOGO = [
17
- "▛▀▀▀▀▀▀▀▜",
18
- " ▚ ▞",
19
- " ▚ ▞",
20
- " ▚ ▞",
21
- " ▘",
22
- ]
23
-
24
- _BRAND_STYLE = "bold cyan"
25
-
26
-
27
- def banner_lines() -> list[tuple[str, str]]:
28
- """(logo_line, tagline) pairs; taglines are empty on the outer rows."""
29
- taglines = [
30
- "",
31
- f"nabla v{__version__}",
32
- "OpenAI call-site harvester",
33
- "scan · record · profile",
34
- "",
35
- ]
36
- return list(zip(_LOGO, taglines))
37
-
38
-
39
- def print_banner(console: Console | None = None) -> None:
40
- console = console or Console()
41
- if not console.is_terminal:
42
- return
43
- console.print()
44
- for logo_line, tagline in banner_lines():
45
- row = Text()
46
- row.append(f" {logo_line:<12}", style=_BRAND_STYLE)
47
- if tagline:
48
- row.append(tagline, style="bold" if tagline.startswith("nabla") else "dim")
49
- console.print(row)
50
- console.print()
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