nabla-cli 0.6.2__tar.gz → 0.6.4__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.6.2 → nabla_cli-0.6.4}/PKG-INFO +1 -1
  2. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/__init__.py +1 -1
  3. nabla_cli-0.6.4/nabla/branding.py +139 -0
  4. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/cli.py +60 -48
  5. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/recorder.py +49 -27
  6. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/scanner.py +2 -1
  7. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/ui.py +53 -1
  8. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla_cli.egg-info/PKG-INFO +1 -1
  9. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/pyproject.toml +1 -1
  10. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_cli_ux.py +51 -0
  11. nabla_cli-0.6.2/nabla/branding.py +0 -101
  12. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/README.md +0 -0
  13. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/__main__.py +0 -0
  14. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/config.py +0 -0
  15. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/contract.py +0 -0
  16. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/induce.py +0 -0
  17. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/profile.py +0 -0
  18. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/prompt_recon.py +0 -0
  19. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/samplegen.py +0 -0
  20. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/schema_resolver.py +0 -0
  21. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/schemas/site_profile.schema.json +0 -0
  22. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla/term.py +0 -0
  23. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla_cli.egg-info/SOURCES.txt +0 -0
  24. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla_cli.egg-info/dependency_links.txt +0 -0
  25. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla_cli.egg-info/entry_points.txt +0 -0
  26. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla_cli.egg-info/requires.txt +0 -0
  27. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/nabla_cli.egg-info/top_level.txt +0 -0
  28. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/setup.cfg +0 -0
  29. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_config.py +0 -0
  30. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_e2e_profile.py +0 -0
  31. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_induce.py +0 -0
  32. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_prompt_recon.py +0 -0
  33. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_recorder.py +0 -0
  34. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_samplegen.py +0 -0
  35. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/tests/test_scanner.py +0 -0
  36. {nabla_cli-0.6.2 → nabla_cli-0.6.4}/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.6.2
3
+ Version: 0.6.4
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.6.2"
3
+ __version__ = "0.6.4"
@@ -0,0 +1,139 @@
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 · capture · build", "cyan"),
38
+ ("", ""),
39
+ ("", ""),
40
+ ]
41
+ return [(logo, text, style) for logo, (text, style) in zip(_LOGO, taglines)]
42
+
43
+
44
+ def _compose() -> Text:
45
+ """The complete banner, final form."""
46
+ frame = Text()
47
+ for logo_line, tagline, tag_style in banner_lines():
48
+ frame.append(f" {logo_line:<14}", style=_BRAND_STYLE)
49
+ if tagline:
50
+ frame.append(tagline, style=tag_style)
51
+ frame.append("\n")
52
+ return frame
53
+
54
+
55
+ def _stroke_frames() -> list[set[tuple[int, int]]]:
56
+ """Reveal order that traces the mark like a pen: the top bar sweeps
57
+ left to right, then the two diagonals descend together to the apex."""
58
+ frames: list[set[tuple[int, int]]] = []
59
+ mask: set[tuple[int, int]] = set()
60
+ top_cells = [(0, c) for c, ch in enumerate(_LOGO[0]) if ch != " "]
61
+ for i in range(0, len(top_cells), 2):
62
+ mask.update(top_cells[i:i + 2])
63
+ frames.append(set(mask))
64
+ for r in range(1, len(_LOGO)):
65
+ mask.update((r, c) for c, ch in enumerate(_LOGO[r]) if ch != " ")
66
+ frames.append(set(mask))
67
+ return frames
68
+
69
+
70
+ def print_banner(console: Console | None = None) -> None:
71
+ """Print the complete banner instantly. Animation, when wanted, happens
72
+ afterwards via animate_in_place() — over this already-rendered region —
73
+ so downstream UI (rails, menus) is on screen before a single frame."""
74
+ console = console or Console()
75
+ if not console.is_terminal:
76
+ return
77
+ console.print()
78
+ console.print(_compose(), end="")
79
+ console.print()
80
+
81
+
82
+ # Raw ANSI for the in-place replay: rich Live owns whole-screen regions,
83
+ # but here the cursor must slip above content that is already printed
84
+ # below the banner, so escape codes are driven directly.
85
+ _ANSI = {"brand": "\x1b[1;36m", "bold": "\x1b[1m", "cyan": "\x1b[36m",
86
+ "": "", "reset": "\x1b[0m"}
87
+
88
+
89
+ def _ansi_row(r: int, mask: set[tuple[int, int]]) -> str:
90
+ logo_line, tagline, tag_style = banner_lines()[r]
91
+ revealed = "".join(
92
+ ch if ch == " " or (r, c) in mask else " "
93
+ for c, ch in enumerate(logo_line)
94
+ )
95
+ row = f" {_ANSI['brand']}{revealed:<14}{_ANSI['reset']}"
96
+ if tagline:
97
+ row += f"{_ANSI[tag_style]}{tagline}{_ANSI['reset']}"
98
+ return row + "\x1b[0K" # clear any residue right of the rewritten text
99
+
100
+
101
+ def animate_in_place(console: Console, lines_below: int) -> None:
102
+ """Replay the logo's stroke-order draw over the banner already on
103
+ screen. ``lines_below`` = rows printed after the banner's trailing
104
+ blank line (rails, menu, ...); the cursor is assumed to sit at the
105
+ start of the row just below them. Everything is rewritten line-for-line
106
+ (taglines included), so the layout cannot shift."""
107
+ if not console.is_terminal:
108
+ return
109
+ import sys as _sys
110
+ import time
111
+
112
+ up = len(_LOGO) + 1 + lines_below # +1: banner's trailing blank line
113
+ write = _sys.stdout.write
114
+ try:
115
+ for mask in _stroke_frames():
116
+ write(f"\x1b[s\x1b[{up}A") # save cursor, jump to logo top
117
+ for r in range(len(_LOGO)):
118
+ write("\r" + _ansi_row(r, mask))
119
+ if r < len(_LOGO) - 1:
120
+ write("\x1b[1B") # down one row
121
+ write("\x1b[u") # restore cursor
122
+ _sys.stdout.flush()
123
+ time.sleep(0.09)
124
+ except Exception: # noqa: BLE001 - cosmetic replay must never break init
125
+ pass
126
+ finally:
127
+ # Terminal state is guaranteed sane: land on the finished mark.
128
+ try:
129
+ full = {(r, c) for r, line in enumerate(_LOGO)
130
+ for c, ch in enumerate(line) if ch != " "}
131
+ write(f"\x1b[s\x1b[{up}A")
132
+ for r in range(len(_LOGO)):
133
+ write("\r" + _ansi_row(r, full))
134
+ if r < len(_LOGO) - 1:
135
+ write("\x1b[1B")
136
+ write("\x1b[u")
137
+ _sys.stdout.flush()
138
+ except Exception: # noqa: BLE001
139
+ pass
@@ -264,9 +264,12 @@ def _read_api_key(prompt: str) -> str:
264
264
 
265
265
 
266
266
  def _cmd_init(args) -> int:
267
+ from .branding import animate_in_place
267
268
  from .ui import RAIL, rail, rail_end, rail_ok, rail_start, select
268
269
 
269
- print_banner(_out, animate=True)
270
+ # Everything renders first — banner (final form), rail, menu — then the
271
+ # logo replays its stroke-order draw in place. Nothing ever shifts.
272
+ print_banner(_out)
270
273
  rail_start(_out, "nabla init")
271
274
  rail(_out)
272
275
 
@@ -274,7 +277,10 @@ def _cmd_init(args) -> int:
274
277
  if provider is None:
275
278
  names = list(PROVIDERS)
276
279
  options = [(name, _PROVIDER_DESCRIPTIONS.get(name, "")) for name in names]
277
- provider = names[select(_out, "Provider", options)]
280
+ provider = names[select(
281
+ _out, "Provider", options,
282
+ on_first_draw=lambda menu_lines: animate_in_place(_out, menu_lines + 2),
283
+ )]
278
284
  else:
279
285
  rail(_out, f"Provider {provider}")
280
286
 
@@ -493,19 +499,9 @@ def _cmd_record(args) -> int:
493
499
  elif args.proxy_cmd:
494
500
  source = RecordSource(kind="proxy", proxy_command=args.proxy_cmd.split())
495
501
  else:
496
- conventional = next(
497
- (p for p in (Path(f"{site.symbol}.inputs.jsonl"),
498
- Path(f"{site.symbol}.samples.jsonl")) # legacy name
499
- if p.exists()),
500
- None,
501
- )
502
- if conventional is not None:
503
- rail(_out, f"using {conventional} (found by convention; pass --inputs to override)")
504
- source = RecordSource(kind="samples", samples_file=str(conventional))
505
- else:
506
- source, source_kind, err = _resolve_inputs_interactively(args, site)
507
- if err is not None:
508
- return err
502
+ source, source_kind, err = _resolve_inputs_interactively(args, site)
503
+ if err is not None:
504
+ return err
509
505
 
510
506
  from .ui import make_progress, rail_end, rail_ok
511
507
 
@@ -554,46 +550,60 @@ def _cmd_record(args) -> int:
554
550
  return 0
555
551
 
556
552
 
557
- def _inputs_menu(label: str) -> tuple[str, object]:
558
- """The interactive inputs menu. Returns ("file", path) or ("generate", n)."""
559
- from .ui import prompt_text, select
553
+ def _inputs_menu(label: str, existing: Path | None = None) -> tuple[str, object]:
554
+ """The interactive inputs menu shown every capture, so reusing a
555
+ previous inputs file is a choice, not an ambush. Returns ("file", path)
556
+ or ("generate", n)."""
557
+ from .ui import prompt_digits, prompt_text, select
560
558
 
561
- options = [
559
+ options = []
560
+ if existing is not None:
561
+ try:
562
+ count = sum(1 for line in existing.read_text(encoding="utf-8").splitlines()
563
+ if line.strip())
564
+ except OSError:
565
+ count = "?"
566
+ options.append(("use existing", f"{existing} · {count} inputs"))
567
+ options += [
562
568
  ("generate 5", "synthetic inputs — quick look, fewest credits"),
563
569
  ("generate 10", "synthetic inputs — balanced"),
564
570
  ("generate 20", "synthetic inputs — handoff floor, most credits"),
565
571
  ("custom count", "type how many synthetic inputs to generate"),
566
572
  ("my own file", "type a path to your JSONL of inputs"),
567
573
  ]
574
+ shift = 1 if existing is not None else 0
568
575
  choice = select(_out, label, options)
569
- if choice == 3:
570
- while True:
571
- typed = prompt_text(_out, "How many", default="5")
572
- if typed.isdigit() and int(typed) > 0:
573
- return "generate", int(typed)
574
- _warn("enter a positive number")
575
- if choice == 4:
576
+ if existing is not None and choice == 0:
577
+ return "file", str(existing)
578
+ if choice == shift + 3:
579
+ return "generate", prompt_digits(_out, "How many", default=5)
580
+ if choice == shift + 4:
576
581
  while True:
577
582
  typed = prompt_text(_out, "Path to JSONL")
578
583
  if typed and Path(typed).exists():
579
584
  return "file", typed
580
585
  _warn(f"{typed or '(empty)'} not found — try again, or Ctrl+C to abort")
581
- return "generate", (5, 10, 20)[choice]
586
+ return "generate", (5, 10, 20)[choice - shift]
582
587
 
583
588
 
584
589
  def _resolve_inputs_interactively(args, site):
585
- """No --inputs given and no conventional file found. Interactively, ask
586
- where inputs come from (generate N / custom count / own file); otherwise
587
- generate the default quietly. Returns (source, source_kind, exit_code)
588
- with exit_code None on success."""
589
- n = args.num_samples
590
- if n is None and _out.is_terminal and sys.stdin.isatty():
591
- kind, value = _inputs_menu("Inputs")
590
+ """No --inputs given. Interactively, ask where inputs come from (existing
591
+ file / generate N / custom count / own file); otherwise use an existing
592
+ conventional file or generate the default quietly. Returns
593
+ (source, source_kind, exit_code) with exit_code None on success."""
594
+ conventional = _find_conventional_inputs(site)
595
+
596
+ if args.num_samples is None and _out.is_terminal and sys.stdin.isatty():
597
+ kind, value = _inputs_menu("Inputs", existing=conventional)
592
598
  if kind == "file":
593
599
  return RecordSource(kind="samples", samples_file=value), None, None
594
600
  n = value
595
- elif n is None:
596
- n = 5
601
+ elif conventional is not None and args.num_samples is None:
602
+ from .ui import rail
603
+ rail(_out, f"using {conventional} (found by convention; pass --inputs to override)")
604
+ return RecordSource(kind="samples", samples_file=str(conventional)), None, None
605
+ else:
606
+ n = args.num_samples or 5
597
607
 
598
608
  source, err = _generate_samples_source(args, site, Path(f"{site.symbol}.inputs.jsonl"), n)
599
609
  if err is not None:
@@ -637,25 +647,27 @@ def _capture_many(args, supported) -> int:
637
647
  rail(_out, f"capturing all {len(targets)} supported call sites")
638
648
 
639
649
  # Phase 1 — decide inputs per site, sequentially (menus can't overlap).
650
+ # The menu is shown every time; an existing inputs file is offered as
651
+ # the default option, never silently assumed.
640
652
  plans: list[dict] = []
641
653
  for site in targets:
642
654
  conventional = _find_conventional_inputs(site)
643
- if conventional is not None:
655
+ if args.num_samples is None and interactive:
656
+ kind, value = _inputs_menu(f"Inputs · {site.symbol}", existing=conventional)
657
+ if kind == "file":
658
+ plans.append({"site": site, "kind": "file", "path": value,
659
+ "source_kind": "samples"})
660
+ else:
661
+ plans.append({"site": site, "kind": "generate", "n": value,
662
+ "source_kind": "generated_samples"})
663
+ continue
664
+ if conventional is not None and args.num_samples is None:
644
665
  rail(_out, f"{site.symbol}: using {conventional}")
645
666
  plans.append({"site": site, "kind": "file", "path": str(conventional),
646
667
  "source_kind": "samples"})
647
668
  continue
648
- if args.num_samples is not None or not interactive:
649
- plans.append({"site": site, "kind": "generate",
650
- "n": args.num_samples or 5, "source_kind": "generated_samples"})
651
- continue
652
- kind, value = _inputs_menu(f"Inputs · {site.symbol}")
653
- if kind == "file":
654
- plans.append({"site": site, "kind": "file", "path": value,
655
- "source_kind": "samples"})
656
- else:
657
- plans.append({"site": site, "kind": "generate", "n": value,
658
- "source_kind": "generated_samples"})
669
+ plans.append({"site": site, "kind": "generate",
670
+ "n": args.num_samples or 5, "source_kind": "generated_samples"})
659
671
 
660
672
  # Phase 2 — run all captures in parallel.
661
673
  progress = make_progress(_out) if _out.is_terminal else None
@@ -131,42 +131,64 @@ def _forward_to_openai_responder(body: dict, headers: dict) -> dict:
131
131
  model_override = os.environ.get("NABLA_UPSTREAM_MODEL")
132
132
  if model_override:
133
133
  body = {**body, "model": model_override}
134
- req = urllib.request.Request(
135
- f"{base_url}/chat/completions",
136
- data=json.dumps(body).encode("utf-8"),
137
- method="POST",
138
- headers={
139
- "Content-Type": "application/json",
140
- "Authorization": auth,
141
- # Some providers' CDNs (e.g. Cloudflare in front of Groq) reject
142
- # urllib's default Python-urllib User-Agent with a 403.
143
- "User-Agent": "nabla-recorder/0.1",
144
- },
145
- )
134
+ request_headers = {
135
+ "Content-Type": "application/json",
136
+ "Authorization": auth,
137
+ # Some providers' CDNs (e.g. Cloudflare in front of Groq) reject
138
+ # urllib's default Python-urllib User-Agent with a 403.
139
+ "User-Agent": "nabla-recorder/0.1",
140
+ }
146
141
  # Rate limits (429) and transient upstream errors are expected during a
147
142
  # long record run, especially on free-tier stand-in oracles: back off and
148
- # retry, honoring Retry-After when the upstream sends one.
143
+ # retry, honoring Retry-After when the upstream sends one. A stand-in
144
+ # that rejects the site's json_schema outright (some providers support a
145
+ # narrower schema dialect, e.g. no type arrays) gets one retry in
146
+ # json_object mode — the production schema in the profile is unaffected.
149
147
  delays = [5, 10, 20, 30, 45]
150
- for attempt, delay in enumerate([*delays, None]):
148
+ attempt = 0
149
+ fell_back = False
150
+ while True:
151
+ req = urllib.request.Request(
152
+ f"{base_url}/chat/completions",
153
+ data=json.dumps(body).encode("utf-8"),
154
+ method="POST",
155
+ headers=request_headers,
156
+ )
151
157
  try:
152
158
  with urllib.request.urlopen(req, timeout=60) as resp:
153
159
  return json.loads(resp.read().decode("utf-8"))
154
160
  except urllib.error.HTTPError as exc:
155
- if delay is None or exc.code not in (429, 500, 502, 503):
156
- # Surface the upstream's error body — "HTTP 400" alone is
157
- # undiagnosable (e.g. Groq's json_validate_failed).
158
- try:
159
- detail = exc.read().decode("utf-8", "replace")[:500]
160
- except Exception: # noqa: BLE001 - body read is best-effort
161
- detail = ""
161
+ try:
162
+ detail = exc.read().decode("utf-8", "replace")[:500]
163
+ except Exception: # noqa: BLE001 - body read is best-effort
164
+ detail = ""
165
+ rf = body.get("response_format")
166
+ if (exc.code == 400 and model_override and not fell_back
167
+ and isinstance(rf, dict) and rf.get("type") == "json_schema"
168
+ and ("schema" in detail.lower() or "invalid" in detail.lower())):
169
+ body = {**body, "response_format": {"type": "json_object"}}
170
+ # Providers require the word "json" in the messages to use
171
+ # json_object mode. This nudge goes only to the stand-in —
172
+ # the proxy captured the original request before forwarding,
173
+ # so the recorded prompt is untouched.
174
+ messages = list(body.get("messages", []))
175
+ if "json" not in json.dumps(messages).lower():
176
+ messages.append({"role": "system", "content": "Respond in JSON."})
177
+ body = {**body, "messages": messages}
178
+ fell_back = True
179
+ print("nabla capture: stand-in oracle rejected this site's json_schema — "
180
+ "retrying in json_object mode (the profile still carries the real schema)",
181
+ file=sys.stderr)
182
+ continue
183
+ if attempt >= len(delays) or exc.code not in (429, 500, 502, 503):
162
184
  raise RuntimeError(f"upstream HTTP {exc.code}: {detail or exc.reason}") from exc
163
185
  retry_after = exc.headers.get("Retry-After") if exc.headers else None
164
186
  try:
165
- wait = max(float(retry_after), delay) if retry_after else delay
187
+ wait = max(float(retry_after), delays[attempt]) if retry_after else delays[attempt]
166
188
  except ValueError:
167
- wait = delay
189
+ wait = delays[attempt]
168
190
  time.sleep(min(wait, 60))
169
- raise RuntimeError("unreachable")
191
+ attempt += 1
170
192
 
171
193
 
172
194
  def _default_responder() -> Responder:
@@ -415,10 +437,10 @@ def _run_site_in_subprocess(site: RawSite, repo_path: str, input_slots: dict, en
415
437
  timeout = int(os.environ.get("NABLA_SAMPLE_TIMEOUT", "300"))
416
438
  proc = subprocess.run(args, cwd=repo_path, env=env, capture_output=True, text=True, timeout=timeout)
417
439
  if proc.returncode != 0:
418
- # The last stderr lines carry the actual exception; the rest is
419
- # traceback scaffolding that buries it.
440
+ # The last stderr line carries the actual exception; everything above
441
+ # it is traceback scaffolding that buries the signal.
420
442
  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"
443
+ detail = lines[-1].strip() if lines else "no output"
422
444
  raise RuntimeError(
423
445
  f"Sample invocation of {site.symbol!r} in {site.file!r} failed "
424
446
  f"(exit {proc.returncode}): {detail}"
@@ -40,7 +40,8 @@ from .contract import (
40
40
  RawSiteRef,
41
41
  )
42
42
 
43
- _SKIP_DIR_NAMES = {".git", "node_modules", "__pycache__", ".venv", "venv"}
43
+ _SKIP_DIR_NAMES = {".git", "node_modules", "__pycache__", ".venv", "venv",
44
+ "build", "dist", ".eggs", ".tox", ".mypy_cache"}
44
45
 
45
46
  _VISION_MARKERS = {"image_url", "input_image"}
46
47
 
@@ -67,9 +67,13 @@ def rail_end(console: Console, text: str, style: str = "cyan") -> None:
67
67
  # ---------------------------------------------------------------------------
68
68
 
69
69
  def select(console: Console, label: str, options: list[tuple[str, str]],
70
- default: int = 0) -> int:
70
+ default: int = 0, on_first_draw=None) -> int:
71
71
  """Pick one of ``options`` ([(name, description), ...]); returns the index.
72
72
 
73
+ ``on_first_draw(lines_drawn)`` fires once after the menu is fully on
74
+ screen, before the first key is read — the hook that lets a banner
75
+ animation replay above an already-complete layout.
76
+
73
77
  Interactive Windows console: arrow keys + enter, pointer rendering,
74
78
  menu collapses to a single confirmation line after the choice.
75
79
  Anywhere else: numbered prompt.
@@ -123,6 +127,11 @@ def select(console: Console, label: str, options: list[tuple[str, str]],
123
127
  lines_drawn = len(options) + 1
124
128
 
125
129
  draw()
130
+ if on_first_draw is not None:
131
+ try:
132
+ on_first_draw(lines_drawn)
133
+ except Exception: # noqa: BLE001 - a cosmetic hook must never break input
134
+ pass
126
135
  while True:
127
136
  ch = msvcrt.getwch()
128
137
  if ch in ("\r", "\n"):
@@ -267,3 +276,46 @@ def make_progress(console: Console, unit: str = "samples") -> Progress:
267
276
  console=console,
268
277
  transient=True,
269
278
  )
279
+
280
+
281
+ def prompt_digits(console: Console, label: str, default: int) -> int:
282
+ """Numeric input that cannot go wrong: on an interactive Windows console
283
+ it reads keys raw — digits echo, backspace works, everything else
284
+ (arrows, brackets, console line-editing artifacts) is ignored. Elsewhere
285
+ it strips non-digits from a plain input()."""
286
+ prompt = Text(f"{RAIL} ", style=_RAIL_STYLE)
287
+ prompt.append(label, style="bold")
288
+ prompt.append(f" [{default}]", style="cyan")
289
+ prompt.append(" ")
290
+
291
+ interactive = (
292
+ sys.platform == "win32"
293
+ and console.is_terminal
294
+ and sys.stdin.isatty()
295
+ )
296
+ if not interactive:
297
+ console.print(prompt, end="")
298
+ raw = "".join(ch for ch in input() if ch.isdigit())
299
+ return int(raw) if raw else default
300
+
301
+ import msvcrt
302
+
303
+ console.print(prompt, end="")
304
+ chars: list[str] = []
305
+ while True:
306
+ ch = msvcrt.getwch()
307
+ if ch in ("\r", "\n"):
308
+ print()
309
+ break
310
+ if ch == "\x03":
311
+ raise KeyboardInterrupt
312
+ if ch == "\b":
313
+ if chars:
314
+ chars.pop()
315
+ print("\b \b", end="", flush=True)
316
+ elif ch.isdigit():
317
+ chars.append(ch)
318
+ print(ch, end="", flush=True)
319
+ elif ch in ("\xe0", "\x00"):
320
+ msvcrt.getwch() # swallow the arrow/function-key payload
321
+ return int("".join(chars)) if chars else default
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nabla-cli
3
- Version: 0.6.2
3
+ Version: 0.6.4
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "nabla-cli"
3
- version = "0.6.2"
3
+ version = "0.6.4"
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" }
@@ -466,3 +466,54 @@ def test_ctrl_c_is_one_quiet_line(monkeypatch, capsys):
466
466
  captured = capsys.readouterr()
467
467
  assert "cancelled" in captured.err
468
468
  assert "Traceback" not in captured.err + captured.out
469
+
470
+
471
+ # ---------------------------------------------------------------------------
472
+ # 0.6.3 fixes: digit prompt, always-shown menu, scan skips build dirs
473
+ # ---------------------------------------------------------------------------
474
+
475
+ def test_prompt_digits_noninteractive_strips_garbage(monkeypatch):
476
+ from rich.console import Console
477
+
478
+ from nabla.ui import prompt_digits
479
+
480
+ console = Console(width=100)
481
+ monkeypatch.setattr("builtins.input", lambda *a: "[1]")
482
+ assert prompt_digits(console, "How many", default=5) == 1
483
+
484
+ monkeypatch.setattr("builtins.input", lambda *a: "")
485
+ assert prompt_digits(console, "How many", default=5) == 5
486
+
487
+
488
+ def test_inputs_menu_offers_existing_file(tmp_path, monkeypatch, capsys):
489
+ from nabla.cli import _inputs_menu
490
+
491
+ existing = tmp_path / "site.inputs.jsonl"
492
+ existing.write_text('{"input_slots": {"q": "x"}}\n' * 3, encoding="utf-8")
493
+ picked = {}
494
+
495
+ def fake_select(console, label, options):
496
+ picked["options"] = options
497
+ return 0 # choose "use existing"
498
+
499
+ monkeypatch.setattr("nabla.ui.select", fake_select)
500
+ kind, value = _inputs_menu("Inputs", existing=existing)
501
+
502
+ assert kind == "file"
503
+ assert value == str(existing)
504
+ assert picked["options"][0][0] == "use existing"
505
+ assert "3 inputs" in picked["options"][0][1]
506
+
507
+
508
+ def test_scan_skips_build_and_dist_dirs(tmp_path, monkeypatch, capsys):
509
+ repo = _make_single_site_repo(tmp_path)
510
+ build_dir = repo / "build" / "lib"
511
+ build_dir.mkdir(parents=True)
512
+ (build_dir / "copy_of_site.py").write_text(_SINGLE_SITE_SRC, encoding="utf-8")
513
+
514
+ rc = main(["scan", str(repo)])
515
+
516
+ assert rc == 0
517
+ out = capsys.readouterr().out
518
+ assert "1 call site found" in out
519
+ assert "copy_of_site" not in out
@@ -1,101 +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
- # 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 · capture · build", "cyan"),
38
- ("", ""),
39
- ("", ""),
40
- ]
41
- return [(logo, text, style) for logo, (text, style) in zip(_LOGO, taglines)]
42
-
43
-
44
- def _compose(traced: set[tuple[int, int]] | None = None) -> Text:
45
- """The full banner. With ``traced`` given, every glyph is still present
46
- (no layout shift, ever) — untraced stroke cells just render quieter, and
47
- the traced set brightens as the animation sweeps through."""
48
- frame = Text()
49
- for r, (logo_line, tagline, tag_style) in enumerate(banner_lines()):
50
- frame.append(" ")
51
- for c, ch in enumerate(f"{logo_line:<14}"):
52
- if ch == " ":
53
- frame.append(" ")
54
- elif traced is None or (r, c) in traced:
55
- frame.append(ch, style=_BRAND_STYLE)
56
- else:
57
- frame.append(ch, style="dim cyan")
58
- if tagline:
59
- frame.append(tagline, style=tag_style)
60
- frame.append("\n")
61
- return frame
62
-
63
-
64
- def _stroke_frames() -> list[set[tuple[int, int]]]:
65
- """Reveal order that traces the mark like a pen: the top bar sweeps
66
- left to right, then the two diagonals descend together to the apex."""
67
- frames: list[set[tuple[int, int]]] = []
68
- mask: set[tuple[int, int]] = set()
69
- top_cells = [(0, c) for c, ch in enumerate(_LOGO[0]) if ch != " "]
70
- for i in range(0, len(top_cells), 2):
71
- mask.update(top_cells[i:i + 2])
72
- frames.append(set(mask))
73
- for r in range(1, len(_LOGO)):
74
- mask.update((r, c) for c, ch in enumerate(_LOGO[r]) if ch != " ")
75
- frames.append(set(mask))
76
- return frames
77
-
78
-
79
- def print_banner(console: Console | None = None, animate: bool = False) -> None:
80
- console = console or Console()
81
- if not console.is_terminal:
82
- return
83
- console.print()
84
- if animate:
85
- import time
86
-
87
- from rich.live import Live
88
-
89
- # Full-size first frame: the complete banner is on screen before a
90
- # single animation tick, so the layout never shifts — the sweep only
91
- # changes brightness along the stroke.
92
- with Live(_compose(traced=set()), console=console,
93
- refresh_per_second=30, transient=False) as live:
94
- time.sleep(0.12)
95
- for traced in _stroke_frames():
96
- live.update(_compose(traced=traced))
97
- time.sleep(0.09)
98
- live.update(_compose())
99
- else:
100
- console.print(_compose(), end="")
101
- 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