nabla-cli 0.4.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.4.0 → nabla_cli-0.4.1}/PKG-INFO +1 -1
  2. {nabla_cli-0.4.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.4.0 → nabla_cli-0.4.1}/nabla/cli.py +48 -10
  5. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla/recorder.py +5 -1
  6. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla/ui.py +2 -1
  7. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/PKG-INFO +1 -1
  8. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/pyproject.toml +1 -1
  9. nabla_cli-0.4.0/nabla/branding.py +0 -56
  10. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/README.md +0 -0
  11. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla/__main__.py +0 -0
  12. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla/config.py +0 -0
  13. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla/contract.py +0 -0
  14. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla/induce.py +0 -0
  15. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla/profile.py +0 -0
  16. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla/prompt_recon.py +0 -0
  17. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla/samplegen.py +0 -0
  18. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla/scanner.py +0 -0
  19. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla/schema_resolver.py +0 -0
  20. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla/schemas/site_profile.schema.json +0 -0
  21. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla/term.py +0 -0
  22. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/SOURCES.txt +0 -0
  23. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/dependency_links.txt +0 -0
  24. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/entry_points.txt +0 -0
  25. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/requires.txt +0 -0
  26. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/top_level.txt +0 -0
  27. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/setup.cfg +0 -0
  28. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/tests/test_cli_ux.py +0 -0
  29. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/tests/test_config.py +0 -0
  30. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/tests/test_e2e_profile.py +0 -0
  31. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/tests/test_induce.py +0 -0
  32. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/tests/test_prompt_recon.py +0 -0
  33. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/tests/test_recorder.py +0 -0
  34. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/tests/test_samplegen.py +0 -0
  35. {nabla_cli-0.4.0 → nabla_cli-0.4.1}/tests/test_scanner.py +0 -0
  36. {nabla_cli-0.4.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.4.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.4.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)
@@ -222,7 +254,7 @@ def _read_api_key(prompt: str) -> str:
222
254
  def _cmd_init(args) -> int:
223
255
  from .ui import RAIL, rail, rail_end, rail_ok, rail_start, select
224
256
 
225
- print_banner(_out)
257
+ print_banner(_out, animate=True)
226
258
  rail_start(_out, "nabla init")
227
259
  rail(_out)
228
260
 
@@ -395,7 +427,7 @@ def _resolve_site(args, sites):
395
427
  if len(supported) == 1:
396
428
  site = supported[0]
397
429
  from .ui import rail
398
- rail(_out, f"using site {site.symbol} (the only supported call site)", style="dim")
430
+ rail(_out, f"using site {site.symbol} (the only supported call site)")
399
431
  return site, None
400
432
 
401
433
  candidates = supported or sites
@@ -442,8 +474,7 @@ def _cmd_record(args) -> int:
442
474
  else:
443
475
  conventional = Path(f"{site.symbol}.samples.jsonl")
444
476
  if conventional.exists():
445
- rail(_out, f"using {conventional} (found by convention; pass --samples to override)",
446
- style="dim")
477
+ rail(_out, f"using {conventional} (found by convention; pass --samples to override)")
447
478
  source = RecordSource(kind="samples", samples_file=str(conventional))
448
479
  else:
449
480
  source, err = _generate_samples_source(args, site, conventional)
@@ -471,6 +502,13 @@ def _cmd_record(args) -> int:
471
502
  except ZeroTrafficError as exc:
472
503
  _fail(f"nabla record: {exc}")
473
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
474
512
  out = args.out or f"{site.symbol}.recorded.jsonl"
475
513
  with open(out, "w", encoding="utf-8") as f:
476
514
  for ex in examples:
@@ -545,7 +583,7 @@ def _cmd_profile(args) -> int:
545
583
  print(f"nabla profile: no recorded examples found ({conventional} or recorded.jsonl). "
546
584
  f"Run first: {record_cmd}", file=sys.stderr)
547
585
  return 2
548
- rail(_out, f"using {examples_path}", style="dim")
586
+ rail(_out, f"using {examples_path}")
549
587
 
550
588
  examples, recorded_from = [], "samples"
551
589
  for line in Path(examples_path).read_text(encoding="utf-8").splitlines():
@@ -415,9 +415,13 @@ 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
 
@@ -110,8 +110,9 @@ def select(console: Console, label: str, options: list[tuple[str, str]],
110
110
  row.append(f" {_POINTER} {_ON} ", style="cyan")
111
111
  row.append(f"{name:<{name_width}}", style="bold cyan")
112
112
  else:
113
+ # Unselected is quieter, never grey — grey reads as disabled.
113
114
  row.append(f" {_OFF} ", style="dim")
114
- row.append(f"{name:<{name_width}}", style="dim")
115
+ row.append(f"{name:<{name_width}}")
115
116
  row.append(f" {desc}", style="dim")
116
117
  console.print(row)
117
118
  lines_drawn = len(options) + 1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nabla-cli
3
- Version: 0.4.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,6 +1,6 @@
1
1
  [project]
2
2
  name = "nabla-cli"
3
- version = "0.4.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,56 +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
- # 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()
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