nabla-cli 0.2.0__tar.gz → 0.2.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 (33) hide show
  1. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/PKG-INFO +1 -1
  2. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/__init__.py +1 -1
  3. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/cli.py +93 -26
  4. nabla_cli-0.2.1/nabla/term.py +53 -0
  5. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla_cli.egg-info/PKG-INFO +1 -1
  6. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla_cli.egg-info/SOURCES.txt +1 -0
  7. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/pyproject.toml +1 -1
  8. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_cli_ux.py +18 -0
  9. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/README.md +0 -0
  10. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/__main__.py +0 -0
  11. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/config.py +0 -0
  12. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/contract.py +0 -0
  13. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/induce.py +0 -0
  14. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/profile.py +0 -0
  15. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/prompt_recon.py +0 -0
  16. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/recorder.py +0 -0
  17. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/samplegen.py +0 -0
  18. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/scanner.py +0 -0
  19. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/schema_resolver.py +0 -0
  20. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/schemas/site_profile.schema.json +0 -0
  21. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla_cli.egg-info/dependency_links.txt +0 -0
  22. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla_cli.egg-info/entry_points.txt +0 -0
  23. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla_cli.egg-info/requires.txt +0 -0
  24. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla_cli.egg-info/top_level.txt +0 -0
  25. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/setup.cfg +0 -0
  26. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_config.py +0 -0
  27. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_e2e_profile.py +0 -0
  28. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_induce.py +0 -0
  29. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_prompt_recon.py +0 -0
  30. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_recorder.py +0 -0
  31. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_samplegen.py +0 -0
  32. {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_scanner.py +0 -0
  33. {nabla_cli-0.2.0 → nabla_cli-0.2.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.2.0
3
+ Version: 0.2.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.2.0"
3
+ __version__ = "0.2.1"
@@ -15,6 +15,7 @@ from .profile import build_profile, find_site
15
15
  from .recorder import RecordSource, ZeroTrafficError, record
16
16
  from .scanner import scan
17
17
  from .schema_resolver import resolve_schema
18
+ from .term import init_terminal, style
18
19
 
19
20
  _PROVIDER_DESCRIPTIONS = {
20
21
  "openai": "real OpenAI API, uses your OPENAI_API_KEY",
@@ -114,7 +115,32 @@ def _build_parser() -> argparse.ArgumentParser:
114
115
  return parser
115
116
 
116
117
 
118
+ def _ok(msg: str) -> None:
119
+ print(style(msg, "green"))
120
+
121
+
122
+ def _warn(msg: str) -> None:
123
+ print(style(msg, "yellow", stream=sys.stderr), file=sys.stderr)
124
+
125
+
126
+ def _fail(msg: str) -> None:
127
+ print(style(msg, "red", stream=sys.stderr), file=sys.stderr)
128
+
129
+
130
+ def _unconfigured() -> bool:
131
+ """True when no provider is set up anywhere — the state where record/
132
+ profile will fail and the user should be nudged toward `nabla init`."""
133
+ try:
134
+ provider = apply_to_env()
135
+ except Exception:
136
+ provider = None
137
+ return provider is None and not (
138
+ os.environ.get("OPENAI_API_KEY") or os.environ.get("NABLA_UPSTREAM_API_KEY")
139
+ )
140
+
141
+
117
142
  def main(argv: list[str] | None = None) -> int:
143
+ init_terminal()
118
144
  # LLM-produced text (goal descriptions, error bodies) can contain
119
145
  # characters legacy Windows consoles (cp1252) can't encode; degrade to
120
146
  # "?" instead of crashing mid-command.
@@ -145,13 +171,42 @@ def main(argv: list[str] | None = None) -> int:
145
171
  return commands[args.command](args)
146
172
 
147
173
 
174
+ def _read_api_key(prompt: str) -> str:
175
+ """Read a secret with visible masking: one `*` per character, backspace
176
+ supported, pasting shows a `*` per pasted character — so the user can
177
+ see that their input (typed or pasted) actually arrived. Falls back to
178
+ fully hidden input where character-level reading isn't available."""
179
+ if sys.platform == "win32" and sys.stdin.isatty():
180
+ import msvcrt
181
+
182
+ print(prompt, end="", flush=True)
183
+ chars: list[str] = []
184
+ while True:
185
+ ch = msvcrt.getwch()
186
+ if ch in ("\r", "\n"):
187
+ print()
188
+ break
189
+ if ch == "\x03": # Ctrl+C
190
+ raise KeyboardInterrupt
191
+ if ch == "\b":
192
+ if chars:
193
+ chars.pop()
194
+ print("\b \b", end="", flush=True)
195
+ elif ch >= " ":
196
+ chars.append(ch)
197
+ print("*", end="", flush=True)
198
+ return "".join(chars).strip()
199
+ return getpass(f"{prompt} (input is hidden — paste and press Enter): ").strip()
200
+
201
+
148
202
  def _cmd_init(args) -> int:
149
203
  provider = args.provider
150
204
  if provider is None:
151
205
  names = list(PROVIDERS)
152
- print("Pick a provider:")
206
+ print(style("Pick a provider:", "bold"))
153
207
  for i, name in enumerate(names, start=1):
154
- print(f" {i}) {name} {_PROVIDER_DESCRIPTIONS.get(name, '')}")
208
+ print(f" {style(f'{i})', 'cyan')} {style(name, 'bold')}"
209
+ f"{_PROVIDER_DESCRIPTIONS.get(name, '')}")
155
210
  choice = input("> ").strip()
156
211
  if choice.isdigit() and 1 <= int(choice) <= len(names):
157
212
  provider = names[int(choice) - 1]
@@ -161,25 +216,25 @@ def _cmd_init(args) -> int:
161
216
  api_key = args.api_key
162
217
  if api_key is None:
163
218
  key_env = PROVIDERS.get(provider, {}).get("api_key_env", "API key")
164
- api_key = getpass(f"Paste your {key_env}: ").strip()
219
+ api_key = _read_api_key(f"Paste your {key_env}: ")
165
220
 
166
221
  if not api_key:
167
- print("nabla init: no API key given", file=sys.stderr)
222
+ _fail("nabla init: no API key given")
168
223
  return 2
169
224
 
170
225
  try:
171
226
  path = save_config(provider, api_key)
172
227
  except ValueError as exc:
173
- print(f"nabla init: {exc}", file=sys.stderr)
228
+ _fail(f"nabla init: {exc}")
174
229
  return 2
175
230
 
176
231
  masked = f"...{api_key[-4:]}" if len(api_key) >= 4 else "..."
177
- print(f"nabla init: saved to {path} (key: {masked})")
232
+ _ok(f"nabla init: saved to {path} (key: {masked})")
178
233
  try:
179
234
  _ensure_scripts_on_path()
180
235
  except Exception:
181
236
  pass # PATH help is best-effort; setup itself already succeeded
182
- print("Setup complete — try: nabla scan")
237
+ print(style("Setup complete — try: nabla scan", "cyan"))
183
238
  return 0
184
239
 
185
240
 
@@ -245,11 +300,26 @@ def _cmd_scan(args) -> int:
245
300
  if args.json:
246
301
  print(json.dumps(rows, indent=2))
247
302
  return 0
248
- print(f"{'SYMBOL':<22} {'FILE':<28} {'KIND':<6} {'VERIFIABILITY':<22} FLAGS")
303
+ print(style(f"{'SYMBOL':<22} {'FILE':<28} {'KIND':<6} {'VERIFIABILITY':<22} FLAGS", "bold"))
304
+ supported_count = 0
249
305
  for r in rows:
250
- print(f"{r['symbol']:<22} {r['file']:<28} {r['kind']:<6} "
251
- f"{r['verifiability']:<22} {','.join(r['flags']) or '-'}")
306
+ verif = r["verifiability"]
307
+ if verif in ("json_schema", "pydantic_parse"):
308
+ verif_color = "green"
309
+ if not r["flags"]:
310
+ supported_count += 1
311
+ elif verif in ("tool_call", "json_object_no_schema"):
312
+ verif_color = "yellow"
313
+ else:
314
+ 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}")
252
319
  print(f"\n{len(rows)} call sites found.")
320
+ if _unconfigured():
321
+ _warn("tip: run `nabla init` once to set up a provider — `nabla record` "
322
+ "and `nabla profile` need one")
253
323
  return 0
254
324
 
255
325
 
@@ -282,7 +352,7 @@ def _resolve_site(args, sites):
282
352
 
283
353
  if len(supported) == 1:
284
354
  site = supported[0]
285
- print(f"nabla: using site {site.symbol} (the only supported call site)")
355
+ print(style(f"nabla: using site {site.symbol} (the only supported call site)", "dim"))
286
356
  return site, None
287
357
 
288
358
  candidates = supported or sites
@@ -307,14 +377,11 @@ def _cmd_record(args) -> int:
307
377
  if err is not None:
308
378
  return err
309
379
 
310
- if apply_to_env() is None and not (
311
- os.environ.get("OPENAI_API_KEY") or os.environ.get("NABLA_UPSTREAM_API_KEY")
312
- ):
313
- print(
380
+ if _unconfigured():
381
+ _warn(
314
382
  "nabla record: warning — no API key configured (OPENAI_API_KEY / "
315
383
  "NABLA_UPSTREAM_API_KEY unset, and no saved config). Run `nabla init` "
316
- "first unless this call site never reaches a real upstream.",
317
- file=sys.stderr,
384
+ "first unless this call site never reaches a real upstream."
318
385
  )
319
386
 
320
387
  source_kind = None # written to the JSONL; may differ from source.kind
@@ -338,17 +405,17 @@ def _cmd_record(args) -> int:
338
405
  try:
339
406
  examples = record(site, source, args.repo)
340
407
  except ZeroTrafficError as exc:
341
- print(f"nabla record: {exc}", file=sys.stderr)
408
+ _fail(f"nabla record: {exc}")
342
409
  return 1
343
410
  out = args.out or f"{site.symbol}.recorded.jsonl"
344
411
  with open(out, "w", encoding="utf-8") as f:
345
412
  for ex in examples:
346
413
  f.write(json.dumps({**ex.to_dict(), "_source_kind": source_kind or source.kind}) + "\n")
347
- print(f"nabla record: {len(examples)} examples -> {out}")
414
+ _ok(f"nabla record: {len(examples)} examples -> {out}")
348
415
  if args.out is None and args.site is None:
349
- print(f"next: nabla profile {args.repo}")
416
+ print(style(f"next: nabla profile {args.repo}", "cyan"))
350
417
  else:
351
- print(f"next: nabla profile {args.repo} --site {site.symbol} --examples {out}")
418
+ print(style(f"next: nabla profile {args.repo} --site {site.symbol} --examples {out}", "cyan"))
352
419
  return 0
353
420
 
354
421
 
@@ -425,12 +492,12 @@ def _cmd_profile(args) -> int:
425
492
  out = args.out or f"{site.symbol}.profile.json"
426
493
  Path(out).write_text(json.dumps(profile, indent=2), encoding="utf-8")
427
494
  supported = profile["classification"]["supported"]
428
- print(f"nabla profile: wrote {out} "
429
- f"(site {profile['site_id'][:12]}…, supported={supported}, "
430
- f"{len(examples)} examples)")
495
+ _ok(f"nabla profile: wrote {out} "
496
+ f"(site {profile['site_id'][:12]}…, supported={supported}, "
497
+ f"{len(examples)} examples)")
431
498
  if len(examples) < 20:
432
- print(f"nabla profile: WARNING — {len(examples)} examples < 20; "
433
- "Phase-0-ready profiles need >=20 (cli-plan §5.4)", file=sys.stderr)
499
+ _warn(f"nabla profile: WARNING — {len(examples)} examples < 20; "
500
+ "Phase-0-ready profiles need >=20 (cli-plan §5.4)")
434
501
  print(f"goal: {goal.description[:100]}")
435
502
  return 0
436
503
 
@@ -0,0 +1,53 @@
1
+ """Minimal terminal styling — ANSI colors with zero dependencies.
2
+
3
+ Colors are applied only when the target stream is a real terminal and
4
+ NO_COLOR is unset, so piped/captured output stays plain (tests, --json
5
+ consumers, CI logs). init_terminal() switches Windows consoles into VT
6
+ mode, which cmd.exe does not enable by default.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+
14
+ _CODES = {
15
+ "bold": "1",
16
+ "dim": "2",
17
+ "red": "31",
18
+ "green": "32",
19
+ "yellow": "33",
20
+ "cyan": "36",
21
+ }
22
+
23
+
24
+ def init_terminal() -> None:
25
+ if sys.platform != "win32":
26
+ return
27
+ try:
28
+ import ctypes
29
+
30
+ kernel32 = ctypes.windll.kernel32
31
+ for handle_id in (-11, -12): # stdout, stderr
32
+ handle = kernel32.GetStdHandle(handle_id)
33
+ mode = ctypes.c_ulong()
34
+ if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
35
+ kernel32.SetConsoleMode(handle, mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING
36
+ except Exception:
37
+ pass # styling is cosmetic; never let it break a command
38
+
39
+
40
+ def supports_color(stream=None) -> bool:
41
+ stream = stream or sys.stdout
42
+ if os.environ.get("NO_COLOR"):
43
+ return False
44
+ return hasattr(stream, "isatty") and stream.isatty()
45
+
46
+
47
+ def style(text: str, *names: str, stream=None) -> str:
48
+ """Wrap text in ANSI codes when the stream supports it. Apply AFTER any
49
+ column padding — escape codes are invisible but count toward len()."""
50
+ if not names or not supports_color(stream):
51
+ return text
52
+ codes = ";".join(_CODES[n] for n in names)
53
+ return f"\x1b[{codes}m{text}\x1b[0m"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nabla-cli
3
- Version: 0.2.0
3
+ Version: 0.2.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
@@ -12,6 +12,7 @@ nabla/recorder.py
12
12
  nabla/samplegen.py
13
13
  nabla/scanner.py
14
14
  nabla/schema_resolver.py
15
+ nabla/term.py
15
16
  nabla/schemas/site_profile.schema.json
16
17
  nabla_cli.egg-info/PKG-INFO
17
18
  nabla_cli.egg-info/SOURCES.txt
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nabla-cli"
3
- version = "0.2.0"
3
+ version = "0.2.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" }
@@ -330,3 +330,21 @@ def test_ensure_scripts_on_path_non_windows_prints_instruction(monkeypatch, caps
330
330
  out = capsys.readouterr().out
331
331
  assert "PATH" in out
332
332
  assert "python -m nabla" in out
333
+
334
+
335
+ def test_scan_prints_init_tip_when_unconfigured(capsys):
336
+ rc = main(["scan", FIXTURE_REPO])
337
+ assert rc == 0
338
+ assert "nabla init" in capsys.readouterr().err
339
+
340
+
341
+ def test_scan_json_output_stays_clean_json(capsys):
342
+ rc = main(["scan", FIXTURE_REPO, "--json"])
343
+ assert rc == 0
344
+ json.loads(capsys.readouterr().out) # must parse — no tip/color on stdout
345
+
346
+
347
+ def test_style_is_plain_when_not_a_tty(capsys):
348
+ from nabla.term import style
349
+
350
+ assert style("hello", "red") == "hello" # captured stdout is not a tty
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