nabla-cli 0.2.0__tar.gz → 0.3.0__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 (34) hide show
  1. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/PKG-INFO +2 -1
  2. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/__init__.py +1 -1
  3. nabla_cli-0.3.0/nabla/branding.py +50 -0
  4. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/cli.py +141 -29
  5. nabla_cli-0.3.0/nabla/term.py +53 -0
  6. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla_cli.egg-info/PKG-INFO +2 -1
  7. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla_cli.egg-info/SOURCES.txt +2 -0
  8. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla_cli.egg-info/requires.txt +1 -0
  9. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/pyproject.toml +2 -1
  10. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_cli_ux.py +20 -2
  11. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/README.md +0 -0
  12. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/__main__.py +0 -0
  13. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/config.py +0 -0
  14. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/contract.py +0 -0
  15. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/induce.py +0 -0
  16. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/profile.py +0 -0
  17. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/prompt_recon.py +0 -0
  18. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/recorder.py +0 -0
  19. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/samplegen.py +0 -0
  20. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/scanner.py +0 -0
  21. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/schema_resolver.py +0 -0
  22. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla/schemas/site_profile.schema.json +0 -0
  23. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla_cli.egg-info/dependency_links.txt +0 -0
  24. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla_cli.egg-info/entry_points.txt +0 -0
  25. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/nabla_cli.egg-info/top_level.txt +0 -0
  26. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/setup.cfg +0 -0
  27. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_config.py +0 -0
  28. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_e2e_profile.py +0 -0
  29. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_induce.py +0 -0
  30. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_prompt_recon.py +0 -0
  31. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_recorder.py +0 -0
  32. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_samplegen.py +0 -0
  33. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/tests/test_scanner.py +0 -0
  34. {nabla_cli-0.2.0 → nabla_cli-0.3.0}/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.3.0
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
@@ -8,6 +8,7 @@ Description-Content-Type: text/markdown
8
8
  Requires-Dist: jsonschema
9
9
  Requires-Dist: pydantic>=2
10
10
  Requires-Dist: openai>=1.40
11
+ Requires-Dist: rich>=13.0
11
12
  Provides-Extra: dev
12
13
  Requires-Dist: pytest; extra == "dev"
13
14
 
@@ -1,3 +1,3 @@
1
1
  """nabla — call-site harvester CLI (pre-Phase-0)."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -0,0 +1,50 @@
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()
@@ -9,13 +9,29 @@ import sys
9
9
  from getpass import getpass
10
10
  from pathlib import Path
11
11
 
12
+ from rich.console import Console
13
+
14
+ from . import __version__
15
+ from .branding import print_banner
12
16
  from .config import PROVIDERS, apply_to_env, save_config
13
- from .contract import Example, is_supported
17
+ from .contract import Example, Verifiability, is_supported
14
18
  from .profile import build_profile, find_site
15
19
  from .recorder import RecordSource, ZeroTrafficError, record
16
20
  from .scanner import scan
17
21
  from .schema_resolver import resolve_schema
18
22
 
23
+
24
+ def _make_console(stderr: bool = False) -> Console:
25
+ console = Console(stderr=stderr, highlight=False)
26
+ if not console.is_terminal:
27
+ # Wide fixed width so captured/piped output never wraps or truncates.
28
+ console = Console(stderr=stderr, highlight=False, width=200)
29
+ return console
30
+
31
+
32
+ _out = _make_console()
33
+ _errcon = _make_console(stderr=True)
34
+
19
35
  _PROVIDER_DESCRIPTIONS = {
20
36
  "openai": "real OpenAI API, uses your OPENAI_API_KEY",
21
37
  "groq": "free tier, openai/gpt-oss-120b",
@@ -36,6 +52,7 @@ def _build_parser() -> argparse.ArgumentParser:
36
52
  " nabla profile write the site profile\n"
37
53
  ),
38
54
  )
55
+ parser.add_argument("--version", action="version", version=f"nabla {__version__}")
39
56
  sub = parser.add_subparsers(dest="command", required=True)
40
57
 
41
58
  p_init = sub.add_parser(
@@ -114,6 +131,34 @@ def _build_parser() -> argparse.ArgumentParser:
114
131
  return parser
115
132
 
116
133
 
134
+ def _ok(msg: str) -> None:
135
+ _out.print(f"✓ {msg}" if _out.is_terminal else msg, style="green", markup=False)
136
+
137
+
138
+ def _warn(msg: str) -> None:
139
+ _errcon.print(msg, style="yellow", markup=False)
140
+
141
+
142
+ def _fail(msg: str) -> None:
143
+ _errcon.print(msg, style="red", markup=False)
144
+
145
+
146
+ def _next_step(cmd: str) -> None:
147
+ _out.print(f"→ next: {cmd}" if _out.is_terminal else f"next: {cmd}", style="cyan", markup=False)
148
+
149
+
150
+ def _unconfigured() -> bool:
151
+ """True when no provider is set up anywhere — the state where record/
152
+ profile will fail and the user should be nudged toward `nabla init`."""
153
+ try:
154
+ provider = apply_to_env()
155
+ except Exception:
156
+ provider = None
157
+ return provider is None and not (
158
+ os.environ.get("OPENAI_API_KEY") or os.environ.get("NABLA_UPSTREAM_API_KEY")
159
+ )
160
+
161
+
117
162
  def main(argv: list[str] | None = None) -> int:
118
163
  # LLM-produced text (goal descriptions, error bodies) can contain
119
164
  # characters legacy Windows consoles (cp1252) can't encode; degrade to
@@ -132,6 +177,7 @@ def main(argv: list[str] | None = None) -> int:
132
177
  parser = _build_parser()
133
178
  argv = sys.argv[1:] if argv is None else argv
134
179
  if not argv:
180
+ print_banner(_out)
135
181
  parser.print_help()
136
182
  return 0
137
183
 
@@ -145,13 +191,43 @@ def main(argv: list[str] | None = None) -> int:
145
191
  return commands[args.command](args)
146
192
 
147
193
 
194
+ def _read_api_key(prompt: str) -> str:
195
+ """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."""
199
+ if sys.platform == "win32" and sys.stdin.isatty():
200
+ import msvcrt
201
+
202
+ print(prompt, end="", flush=True)
203
+ chars: list[str] = []
204
+ while True:
205
+ ch = msvcrt.getwch()
206
+ if ch in ("\r", "\n"):
207
+ print()
208
+ break
209
+ if ch == "\x03": # Ctrl+C
210
+ raise KeyboardInterrupt
211
+ if ch == "\b":
212
+ if chars:
213
+ chars.pop()
214
+ print("\b \b", end="", flush=True)
215
+ elif ch >= " ":
216
+ chars.append(ch)
217
+ print("*", end="", flush=True)
218
+ return "".join(chars).strip()
219
+ return getpass(f"{prompt} (input is hidden — paste and press Enter): ").strip()
220
+
221
+
148
222
  def _cmd_init(args) -> int:
223
+ print_banner(_out)
149
224
  provider = args.provider
150
225
  if provider is None:
151
226
  names = list(PROVIDERS)
152
- print("Pick a provider:")
227
+ _out.print("Pick a provider:", style="bold")
153
228
  for i, name in enumerate(names, start=1):
154
- print(f" {i}) {name} — {_PROVIDER_DESCRIPTIONS.get(name, '')}")
229
+ _out.print(f" [cyan]{i})[/cyan] [bold]{name}[/bold] — "
230
+ f"{_PROVIDER_DESCRIPTIONS.get(name, '')}")
155
231
  choice = input("> ").strip()
156
232
  if choice.isdigit() and 1 <= int(choice) <= len(names):
157
233
  provider = names[int(choice) - 1]
@@ -161,25 +237,25 @@ def _cmd_init(args) -> int:
161
237
  api_key = args.api_key
162
238
  if api_key is None:
163
239
  key_env = PROVIDERS.get(provider, {}).get("api_key_env", "API key")
164
- api_key = getpass(f"Paste your {key_env}: ").strip()
240
+ api_key = _read_api_key(f"Paste your {key_env}: ")
165
241
 
166
242
  if not api_key:
167
- print("nabla init: no API key given", file=sys.stderr)
243
+ _fail("nabla init: no API key given")
168
244
  return 2
169
245
 
170
246
  try:
171
247
  path = save_config(provider, api_key)
172
248
  except ValueError as exc:
173
- print(f"nabla init: {exc}", file=sys.stderr)
249
+ _fail(f"nabla init: {exc}")
174
250
  return 2
175
251
 
176
252
  masked = f"...{api_key[-4:]}" if len(api_key) >= 4 else "..."
177
- print(f"nabla init: saved to {path} (key: {masked})")
253
+ _ok(f"nabla init: saved to {path} (key: {masked})")
178
254
  try:
179
255
  _ensure_scripts_on_path()
180
256
  except Exception:
181
257
  pass # PATH help is best-effort; setup itself already succeeded
182
- print("Setup complete try: nabla scan")
258
+ _next_step("nabla scan (run it from inside the repo you want to harvest)")
183
259
  return 0
184
260
 
185
261
 
@@ -245,11 +321,42 @@ def _cmd_scan(args) -> int:
245
321
  if args.json:
246
322
  print(json.dumps(rows, indent=2))
247
323
  return 0
248
- print(f"{'SYMBOL':<22} {'FILE':<28} {'KIND':<6} {'VERIFIABILITY':<22} FLAGS")
324
+
325
+ from rich import box as rich_box
326
+ from rich.table import Table
327
+ from rich.text import Text
328
+
329
+ table = Table(box=rich_box.SIMPLE_HEAD, header_style="bold", pad_edge=False)
330
+ for col in ("SYMBOL", "FILE", "KIND", "VERIFIABILITY", "HARVESTABLE", "FLAGS"):
331
+ table.add_column(col, overflow="fold")
332
+ supported_count = 0
249
333
  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 '-'}")
252
- print(f"\n{len(rows)} call sites found.")
334
+ verif = r["verifiability"]
335
+ harvestable = is_supported(Verifiability(verif), r["flags"])
336
+ if harvestable:
337
+ supported_count += 1
338
+ if verif in ("json_schema", "pydantic_parse"):
339
+ verif_color = "green"
340
+ elif verif in ("tool_call", "json_object_no_schema"):
341
+ verif_color = "yellow"
342
+ else:
343
+ verif_color = "dim"
344
+ table.add_row(
345
+ Text(r["symbol"], style="cyan"),
346
+ r["file"],
347
+ r["kind"],
348
+ Text(verif, style=verif_color),
349
+ Text("yes", style="green") if harvestable else Text("no", style="dim"),
350
+ ",".join(r["flags"]) or "-",
351
+ )
352
+ _out.print(table)
353
+ noun = "call site" if len(rows) == 1 else "call sites"
354
+ _out.print(f"{len(rows)} {noun} found — {supported_count} harvestable.", markup=False)
355
+ if supported_count:
356
+ _next_step("nabla record" + (f" {args.repo}" if args.repo != "." else ""))
357
+ if _unconfigured():
358
+ _warn("tip: run `nabla init` once to set up a provider — `nabla record` "
359
+ "and `nabla profile` need one")
253
360
  return 0
254
361
 
255
362
 
@@ -282,7 +389,8 @@ def _resolve_site(args, sites):
282
389
 
283
390
  if len(supported) == 1:
284
391
  site = supported[0]
285
- print(f"nabla: using site {site.symbol} (the only supported call site)")
392
+ _out.print(f"nabla: using site {site.symbol} (the only supported call site)",
393
+ style="dim", markup=False)
286
394
  return site, None
287
395
 
288
396
  candidates = supported or sites
@@ -307,14 +415,11 @@ def _cmd_record(args) -> int:
307
415
  if err is not None:
308
416
  return err
309
417
 
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(
418
+ if _unconfigured():
419
+ _warn(
314
420
  "nabla record: warning — no API key configured (OPENAI_API_KEY / "
315
421
  "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,
422
+ "first unless this call site never reaches a real upstream."
318
423
  )
319
424
 
320
425
  source_kind = None # written to the JSONL; may differ from source.kind
@@ -338,17 +443,21 @@ def _cmd_record(args) -> int:
338
443
  try:
339
444
  examples = record(site, source, args.repo)
340
445
  except ZeroTrafficError as exc:
341
- print(f"nabla record: {exc}", file=sys.stderr)
446
+ _fail(f"nabla record: {exc}")
342
447
  return 1
343
448
  out = args.out or f"{site.symbol}.recorded.jsonl"
344
449
  with open(out, "w", encoding="utf-8") as f:
345
450
  for ex in examples:
346
451
  f.write(json.dumps({**ex.to_dict(), "_source_kind": source_kind or source.kind}) + "\n")
347
- print(f"nabla record: {len(examples)} examples -> {out}")
452
+ _ok(f"recorded {len(examples)} (prompt, completion) pairs from "
453
+ f"site {site.symbol} -> {out}")
454
+ 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")
348
457
  if args.out is None and args.site is None:
349
- print(f"next: nabla profile {args.repo}")
458
+ _next_step(f"nabla profile{'' if args.repo == '.' else ' ' + args.repo}")
350
459
  else:
351
- print(f"next: nabla profile {args.repo} --site {site.symbol} --examples {out}")
460
+ _next_step(f"nabla profile {args.repo} --site {site.symbol} --examples {out}")
352
461
  return 0
353
462
 
354
463
 
@@ -425,13 +534,16 @@ def _cmd_profile(args) -> int:
425
534
  out = args.out or f"{site.symbol}.profile.json"
426
535
  Path(out).write_text(json.dumps(profile, indent=2), encoding="utf-8")
427
536
  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)")
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)
542
+ dims = ", ".join(d["name"] for d in goal.difficulty_dimensions[:4])
543
+ _out.print(f" difficulty {dims}", markup=False)
431
544
  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)
434
- print(f"goal: {goal.description[:100]}")
545
+ _warn(f"only {len(examples)} examples (< 20) — enough for a look, "
546
+ "but a handoff-quality profile wants 20+ real-traffic pairs")
435
547
  return 0
436
548
 
437
549
 
@@ -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.3.0
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
@@ -8,6 +8,7 @@ Description-Content-Type: text/markdown
8
8
  Requires-Dist: jsonschema
9
9
  Requires-Dist: pydantic>=2
10
10
  Requires-Dist: openai>=1.40
11
+ Requires-Dist: rich>=13.0
11
12
  Provides-Extra: dev
12
13
  Requires-Dist: pytest; extra == "dev"
13
14
 
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  nabla/__init__.py
4
4
  nabla/__main__.py
5
+ nabla/branding.py
5
6
  nabla/cli.py
6
7
  nabla/config.py
7
8
  nabla/contract.py
@@ -12,6 +13,7 @@ nabla/recorder.py
12
13
  nabla/samplegen.py
13
14
  nabla/scanner.py
14
15
  nabla/schema_resolver.py
16
+ nabla/term.py
15
17
  nabla/schemas/site_profile.schema.json
16
18
  nabla_cli.egg-info/PKG-INFO
17
19
  nabla_cli.egg-info/SOURCES.txt
@@ -1,6 +1,7 @@
1
1
  jsonschema
2
2
  pydantic>=2
3
3
  openai>=1.40
4
+ rich>=13.0
4
5
 
5
6
  [dev]
6
7
  pytest
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nabla-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
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" }
@@ -9,6 +9,7 @@ dependencies = [
9
9
  "jsonschema",
10
10
  "pydantic>=2",
11
11
  "openai>=1.40",
12
+ "rich>=13.0",
12
13
  ]
13
14
 
14
15
  [project.optional-dependencies]
@@ -80,7 +80,7 @@ def test_init_writes_config(tmp_path, monkeypatch, capsys):
80
80
  out = capsys.readouterr().out
81
81
  assert "gsk_test" not in out # the full key must never be echoed
82
82
  assert "test" in out # masked last-4 tail is shown
83
- assert "Setup complete — try: nabla scan" in out
83
+ assert "next: nabla scan" in out
84
84
 
85
85
 
86
86
  def test_init_bad_provider_exits_nonzero_with_helpful_message(capsys):
@@ -282,7 +282,7 @@ def test_profile_finds_recorded_file_by_convention(tmp_path, monkeypatch, capsys
282
282
  assert (tmp_path / "extract_receipt.profile.json").exists()
283
283
  stdout = capsys.readouterr().out
284
284
  assert "using extract_receipt.recorded.jsonl" in stdout
285
- assert "goal: Extracts receipt fields." in stdout
285
+ assert "Extracts receipt fields." in stdout
286
286
 
287
287
 
288
288
  def test_profile_without_recorded_file_exits_2_with_record_command(tmp_path, monkeypatch, capsys):
@@ -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