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.
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/PKG-INFO +1 -1
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/__init__.py +1 -1
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/cli.py +93 -26
- nabla_cli-0.2.1/nabla/term.py +53 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla_cli.egg-info/PKG-INFO +1 -1
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla_cli.egg-info/SOURCES.txt +1 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/pyproject.toml +1 -1
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_cli_ux.py +18 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/README.md +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/__main__.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/config.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/contract.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/induce.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/profile.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/prompt_recon.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/recorder.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/samplegen.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/scanner.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/schema_resolver.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla/schemas/site_profile.schema.json +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla_cli.egg-info/dependency_links.txt +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla_cli.egg-info/entry_points.txt +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla_cli.egg-info/requires.txt +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/nabla_cli.egg-info/top_level.txt +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/setup.cfg +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_config.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_e2e_profile.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_induce.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_prompt_recon.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_recorder.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_samplegen.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_scanner.py +0 -0
- {nabla_cli-0.2.0 → nabla_cli-0.2.1}/tests/test_schema_resolver.py +0 -0
|
@@ -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})
|
|
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 =
|
|
219
|
+
api_key = _read_api_key(f"Paste your {key_env}: ")
|
|
165
220
|
|
|
166
221
|
if not api_key:
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
433
|
-
"Phase-0-ready profiles need >=20 (cli-plan §5.4)"
|
|
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"
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|