nabla-cli 0.4.0__tar.gz → 0.5.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 (36) hide show
  1. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/PKG-INFO +33 -33
  2. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/README.md +32 -32
  3. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/__init__.py +1 -1
  4. nabla_cli-0.5.0/nabla/branding.py +94 -0
  5. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/cli.py +119 -58
  6. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/recorder.py +8 -4
  7. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/samplegen.py +5 -5
  8. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/ui.py +10 -21
  9. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla_cli.egg-info/PKG-INFO +33 -33
  10. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/pyproject.toml +1 -1
  11. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_cli_ux.py +9 -9
  12. nabla_cli-0.4.0/nabla/branding.py +0 -56
  13. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/__main__.py +0 -0
  14. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/config.py +0 -0
  15. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/contract.py +0 -0
  16. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/induce.py +0 -0
  17. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/profile.py +0 -0
  18. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/prompt_recon.py +0 -0
  19. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/scanner.py +0 -0
  20. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/schema_resolver.py +0 -0
  21. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/schemas/site_profile.schema.json +0 -0
  22. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/term.py +0 -0
  23. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla_cli.egg-info/SOURCES.txt +0 -0
  24. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla_cli.egg-info/dependency_links.txt +0 -0
  25. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla_cli.egg-info/entry_points.txt +0 -0
  26. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla_cli.egg-info/requires.txt +0 -0
  27. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla_cli.egg-info/top_level.txt +0 -0
  28. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/setup.cfg +0 -0
  29. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_config.py +0 -0
  30. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_e2e_profile.py +0 -0
  31. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_induce.py +0 -0
  32. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_prompt_recon.py +0 -0
  33. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_recorder.py +0 -0
  34. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_samplegen.py +0 -0
  35. {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_scanner.py +0 -0
  36. {nabla_cli-0.4.0 → nabla_cli-0.5.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.4.0
3
+ Version: 0.5.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
@@ -63,19 +63,19 @@ pip install nabla-cli
63
63
 
64
64
  nabla init # one-time setup, once per machine
65
65
  nabla scan
66
- nabla record
67
- nabla profile
66
+ nabla capture
67
+ nabla build
68
68
  ```
69
69
 
70
70
  No flags needed on the happy path: the repo defaults to the current
71
71
  directory, `--site` is auto-picked when the repo has exactly one
72
72
  supported call site (with more than one, pass `--site <symbol>` — the
73
- names come from `scan`'s output), `record` writes
74
- `<site>.recorded.jsonl`, and `profile` finds that file automatically by
73
+ names come from `scan`'s output), `capture` writes
74
+ `<site>.captured.jsonl`, and `build` finds that file automatically by
75
75
  the same convention.
76
76
 
77
- If you don't pass `--samples`, `record` looks for
78
- `<site>.samples.jsonl` and otherwise generates a handful of synthetic
77
+ If you don't pass `--inputs`, `capture` looks for
78
+ `<site>.inputs.jsonl` and otherwise generates a handful of synthetic
79
79
  inputs from the site's prompt template (saving them to that file so you
80
80
  can edit them and rerun with realistic data). Profiles built on
81
81
  generated inputs are marked `generated_samples` in their provenance —
@@ -110,23 +110,23 @@ extract_invoice app/invoice_extract.py create json_schema -
110
110
  1 call site found.
111
111
  ```
112
112
 
113
- ### `nabla record`
113
+ ### `nabla capture`
114
114
 
115
115
  One progress line per sample, then a summary:
116
116
 
117
117
  ```
118
- nabla record: sample 1/6 -> 1 capture(s)
119
- nabla record: sample 2/6 -> 1 capture(s)
118
+ nabla capture: sample 1/6 -> 1 capture(s)
119
+ nabla capture: sample 2/6 -> 1 capture(s)
120
120
  ...
121
- nabla record: 6 examples -> extract_invoice.recorded.jsonl
121
+ captured 6 (prompt, completion) pairs from site extract_invoice -> extract_invoice.captured.jsonl
122
122
  ```
123
123
 
124
- `--out` defaults to `<site>.recorded.jsonl` in the current directory (so
125
- `profile` can find it by the same convention afterward) — pass `--out`
124
+ `--out` defaults to `<site>.captured.jsonl` in the current directory (so
125
+ `build` can find it by the same convention afterward) — pass `--out`
126
126
  yourself only to pick a different name or location.
127
127
 
128
- To record your own inputs instead of generated ones, pass `--samples`
129
- (or create `<site>.samples.jsonl`, which is picked up automatically): a
128
+ To record your own inputs instead of generated ones, pass `--inputs`
129
+ (or create `<site>.inputs.jsonl`, which is picked up automatically): a
130
130
  JSONL file, one line per call to record. Each line is
131
131
  `{"input_slots": {...}}`, where the keys match the site function's
132
132
  keyword arguments (as reported by `scan`):
@@ -139,19 +139,19 @@ keyword arguments (as reported by `scan`):
139
139
  nabla runs the site's function once per line, in a subprocess, with its
140
140
  OpenAI client rerouted through a local recording proxy.
141
141
 
142
- ### `nabla profile`
142
+ ### `nabla build`
143
143
 
144
144
  ```
145
- nabla profile: goal — "Extract structured invoice fields (total, currency,
145
+ nabla build: goal — "Extract structured invoice fields (total, currency,
146
146
  due date) from free-form invoice or receipt text, normalizing
147
147
  locale-specific number and date formats."
148
- nabla profile: wrote extract_invoice.profile.json (site 4f2a9b1c3d8e…, supported=True, 6 examples)
148
+ nabla build: wrote extract_invoice.profile.json (site 4f2a9b1c3d8e…, supported=True, 6 examples)
149
149
  ```
150
150
 
151
151
  `--out` defaults to `<site>.profile.json` in the current directory.
152
- `--examples` is optional: `profile` looks for `<site>.recorded.jsonl` in
153
- the current directory — the file `record` just wrote — and you only need
154
- `--examples <path>` to point it at a different recording. The one LLM
152
+ `--captured` is optional: `build` looks for `<site>.captured.jsonl` in
153
+ the current directory — the file `capture` just wrote — and you only need
154
+ `--captured <path>` to point it at a different recording. The one LLM
155
155
  call in this step is goal inference: it reads the reconstructed prompt
156
156
  template, the resolved output schema, and the recorded examples, and
157
157
  returns a goal description, implicit constraints, and difficulty
@@ -165,16 +165,16 @@ for — it doesn't need to be the provider your target repo already calls.
165
165
 
166
166
  | Provider | Default model | Role |
167
167
  |---|---|---|
168
- | `openai` | your repo's own model (goal inference uses `gpt-4o-mini`) | Real upstream — `record` calls OpenAI directly with your key, auth passthrough. |
169
- | `groq` | `openai/gpt-oss-120b` | Stand-in oracle — `record` calls Groq instead of OpenAI. |
170
- | `gemini` | `gemini-3.5-flash` | Stand-in oracle — `record` calls Gemini instead of OpenAI. |
168
+ | `openai` | your repo's own model (goal inference uses `gpt-4o-mini`) | Real upstream — `capture` calls OpenAI directly with your key, auth passthrough. |
169
+ | `groq` | `openai/gpt-oss-120b` | Stand-in oracle — `capture` calls Groq instead of OpenAI. |
170
+ | `gemini` | `gemini-3.5-flash` | Stand-in oracle — `capture` calls Gemini instead of OpenAI. |
171
171
 
172
172
  When you pick `groq` or `gemini`, they only stand in for *generating* the
173
- completions during `record`. The profile's cost/latency baseline always
173
+ completions during `capture`. The profile's cost/latency baseline always
174
174
  reports numbers for the target repo's own model — the one your code
175
175
  actually calls in production — never the stand-in's.
176
176
 
177
- Gemini's free tier has a very low daily quota: expect `record` to pace
177
+ Gemini's free tier has a very low daily quota: expect `capture` to pace
178
178
  requests with multi-second delays and to still hit rate limits past a
179
179
  handful of samples. `groq` or `openai` handle larger sample sets more
180
180
  comfortably.
@@ -198,11 +198,11 @@ work and take priority over it:
198
198
 
199
199
  - `--site <symbol>` — skip site auto-pick; required once a repo has more
200
200
  than one supported call site.
201
- - `--out <path>` (`record`) — write recorded examples somewhere other than
202
- `<site>.recorded.jsonl`.
203
- - `--examples <path>` (`profile`) — read recorded examples from somewhere
204
- other than `<site>.recorded.jsonl`.
205
- - `--out <path>` (`profile`) — write the profile somewhere other than
201
+ - `--out <path>` (`capture`) — write recorded examples somewhere other than
202
+ `<site>.captured.jsonl`.
203
+ - `--captured <path>` (`build`) — read recorded examples from somewhere
204
+ other than `<site>.captured.jsonl`.
205
+ - `--out <path>` (`build`) — write the profile somewhere other than
206
206
  `<site>.profile.json`.
207
207
 
208
208
  ### Environment variables
@@ -216,7 +216,7 @@ highest first:
216
216
 
217
217
  | Variable | Purpose |
218
218
  |---|---|
219
- | `OPENAI_API_KEY` | Default upstream for `record` (auth passthrough). |
219
+ | `OPENAI_API_KEY` | Default upstream for `capture` (auth passthrough). |
220
220
  | `GROQ_API_KEY` / `GEMINI_API_KEY` | That provider's conventional key env, read if `~/.nabla/config.json` has no stored key. |
221
221
  | `NABLA_PROVIDER` | Overrides the provider `nabla init` selected. |
222
222
  | `NABLA_UPSTREAM_BASE_URL` | Record through any OpenAI-compatible stand-in oracle instead. |
@@ -228,7 +228,7 @@ highest first:
228
228
 
229
229
  ## Notes
230
230
 
231
- - `record` runs the site's enclosing function in a subprocess per sample,
231
+ - `capture` runs the site's enclosing function in a subprocess per sample,
232
232
  with `OPENAI_BASE_URL` pointed at a local recording proxy. If the
233
233
  target's tests mock the SDK, the proxy path reports zero traffic
234
234
  explicitly instead of writing an empty profile.
@@ -49,19 +49,19 @@ pip install nabla-cli
49
49
 
50
50
  nabla init # one-time setup, once per machine
51
51
  nabla scan
52
- nabla record
53
- nabla profile
52
+ nabla capture
53
+ nabla build
54
54
  ```
55
55
 
56
56
  No flags needed on the happy path: the repo defaults to the current
57
57
  directory, `--site` is auto-picked when the repo has exactly one
58
58
  supported call site (with more than one, pass `--site <symbol>` — the
59
- names come from `scan`'s output), `record` writes
60
- `<site>.recorded.jsonl`, and `profile` finds that file automatically by
59
+ names come from `scan`'s output), `capture` writes
60
+ `<site>.captured.jsonl`, and `build` finds that file automatically by
61
61
  the same convention.
62
62
 
63
- If you don't pass `--samples`, `record` looks for
64
- `<site>.samples.jsonl` and otherwise generates a handful of synthetic
63
+ If you don't pass `--inputs`, `capture` looks for
64
+ `<site>.inputs.jsonl` and otherwise generates a handful of synthetic
65
65
  inputs from the site's prompt template (saving them to that file so you
66
66
  can edit them and rerun with realistic data). Profiles built on
67
67
  generated inputs are marked `generated_samples` in their provenance —
@@ -96,23 +96,23 @@ extract_invoice app/invoice_extract.py create json_schema -
96
96
  1 call site found.
97
97
  ```
98
98
 
99
- ### `nabla record`
99
+ ### `nabla capture`
100
100
 
101
101
  One progress line per sample, then a summary:
102
102
 
103
103
  ```
104
- nabla record: sample 1/6 -> 1 capture(s)
105
- nabla record: sample 2/6 -> 1 capture(s)
104
+ nabla capture: sample 1/6 -> 1 capture(s)
105
+ nabla capture: sample 2/6 -> 1 capture(s)
106
106
  ...
107
- nabla record: 6 examples -> extract_invoice.recorded.jsonl
107
+ captured 6 (prompt, completion) pairs from site extract_invoice -> extract_invoice.captured.jsonl
108
108
  ```
109
109
 
110
- `--out` defaults to `<site>.recorded.jsonl` in the current directory (so
111
- `profile` can find it by the same convention afterward) — pass `--out`
110
+ `--out` defaults to `<site>.captured.jsonl` in the current directory (so
111
+ `build` can find it by the same convention afterward) — pass `--out`
112
112
  yourself only to pick a different name or location.
113
113
 
114
- To record your own inputs instead of generated ones, pass `--samples`
115
- (or create `<site>.samples.jsonl`, which is picked up automatically): a
114
+ To record your own inputs instead of generated ones, pass `--inputs`
115
+ (or create `<site>.inputs.jsonl`, which is picked up automatically): a
116
116
  JSONL file, one line per call to record. Each line is
117
117
  `{"input_slots": {...}}`, where the keys match the site function's
118
118
  keyword arguments (as reported by `scan`):
@@ -125,19 +125,19 @@ keyword arguments (as reported by `scan`):
125
125
  nabla runs the site's function once per line, in a subprocess, with its
126
126
  OpenAI client rerouted through a local recording proxy.
127
127
 
128
- ### `nabla profile`
128
+ ### `nabla build`
129
129
 
130
130
  ```
131
- nabla profile: goal — "Extract structured invoice fields (total, currency,
131
+ nabla build: goal — "Extract structured invoice fields (total, currency,
132
132
  due date) from free-form invoice or receipt text, normalizing
133
133
  locale-specific number and date formats."
134
- nabla profile: wrote extract_invoice.profile.json (site 4f2a9b1c3d8e…, supported=True, 6 examples)
134
+ nabla build: wrote extract_invoice.profile.json (site 4f2a9b1c3d8e…, supported=True, 6 examples)
135
135
  ```
136
136
 
137
137
  `--out` defaults to `<site>.profile.json` in the current directory.
138
- `--examples` is optional: `profile` looks for `<site>.recorded.jsonl` in
139
- the current directory — the file `record` just wrote — and you only need
140
- `--examples <path>` to point it at a different recording. The one LLM
138
+ `--captured` is optional: `build` looks for `<site>.captured.jsonl` in
139
+ the current directory — the file `capture` just wrote — and you only need
140
+ `--captured <path>` to point it at a different recording. The one LLM
141
141
  call in this step is goal inference: it reads the reconstructed prompt
142
142
  template, the resolved output schema, and the recorded examples, and
143
143
  returns a goal description, implicit constraints, and difficulty
@@ -151,16 +151,16 @@ for — it doesn't need to be the provider your target repo already calls.
151
151
 
152
152
  | Provider | Default model | Role |
153
153
  |---|---|---|
154
- | `openai` | your repo's own model (goal inference uses `gpt-4o-mini`) | Real upstream — `record` calls OpenAI directly with your key, auth passthrough. |
155
- | `groq` | `openai/gpt-oss-120b` | Stand-in oracle — `record` calls Groq instead of OpenAI. |
156
- | `gemini` | `gemini-3.5-flash` | Stand-in oracle — `record` calls Gemini instead of OpenAI. |
154
+ | `openai` | your repo's own model (goal inference uses `gpt-4o-mini`) | Real upstream — `capture` calls OpenAI directly with your key, auth passthrough. |
155
+ | `groq` | `openai/gpt-oss-120b` | Stand-in oracle — `capture` calls Groq instead of OpenAI. |
156
+ | `gemini` | `gemini-3.5-flash` | Stand-in oracle — `capture` calls Gemini instead of OpenAI. |
157
157
 
158
158
  When you pick `groq` or `gemini`, they only stand in for *generating* the
159
- completions during `record`. The profile's cost/latency baseline always
159
+ completions during `capture`. The profile's cost/latency baseline always
160
160
  reports numbers for the target repo's own model — the one your code
161
161
  actually calls in production — never the stand-in's.
162
162
 
163
- Gemini's free tier has a very low daily quota: expect `record` to pace
163
+ Gemini's free tier has a very low daily quota: expect `capture` to pace
164
164
  requests with multi-second delays and to still hit rate limits past a
165
165
  handful of samples. `groq` or `openai` handle larger sample sets more
166
166
  comfortably.
@@ -184,11 +184,11 @@ work and take priority over it:
184
184
 
185
185
  - `--site <symbol>` — skip site auto-pick; required once a repo has more
186
186
  than one supported call site.
187
- - `--out <path>` (`record`) — write recorded examples somewhere other than
188
- `<site>.recorded.jsonl`.
189
- - `--examples <path>` (`profile`) — read recorded examples from somewhere
190
- other than `<site>.recorded.jsonl`.
191
- - `--out <path>` (`profile`) — write the profile somewhere other than
187
+ - `--out <path>` (`capture`) — write recorded examples somewhere other than
188
+ `<site>.captured.jsonl`.
189
+ - `--captured <path>` (`build`) — read recorded examples from somewhere
190
+ other than `<site>.captured.jsonl`.
191
+ - `--out <path>` (`build`) — write the profile somewhere other than
192
192
  `<site>.profile.json`.
193
193
 
194
194
  ### Environment variables
@@ -202,7 +202,7 @@ highest first:
202
202
 
203
203
  | Variable | Purpose |
204
204
  |---|---|
205
- | `OPENAI_API_KEY` | Default upstream for `record` (auth passthrough). |
205
+ | `OPENAI_API_KEY` | Default upstream for `capture` (auth passthrough). |
206
206
  | `GROQ_API_KEY` / `GEMINI_API_KEY` | That provider's conventional key env, read if `~/.nabla/config.json` has no stored key. |
207
207
  | `NABLA_PROVIDER` | Overrides the provider `nabla init` selected. |
208
208
  | `NABLA_UPSTREAM_BASE_URL` | Record through any OpenAI-compatible stand-in oracle instead. |
@@ -214,7 +214,7 @@ highest first:
214
214
 
215
215
  ## Notes
216
216
 
217
- - `record` runs the site's enclosing function in a subprocess per sample,
217
+ - `capture` runs the site's enclosing function in a subprocess per sample,
218
218
  with `OPENAI_BASE_URL` pointed at a local recording proxy. If the
219
219
  target's tests mock the SDK, the proxy path reports zero traffic
220
220
  explicitly instead of writing an empty profile.
@@ -1,3 +1,3 @@
1
1
  """nabla — call-site harvester CLI (pre-Phase-0)."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
@@ -0,0 +1,94 @@
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(mask: set[tuple[int, int]] | None = None, with_taglines: bool = True) -> Text:
45
+ frame = Text()
46
+ for r, (logo_line, tagline, tag_style) in enumerate(banner_lines()):
47
+ if mask is None:
48
+ segment = logo_line
49
+ else:
50
+ segment = "".join(
51
+ ch if ch == " " or (r, c) in mask else " "
52
+ for c, ch in enumerate(logo_line)
53
+ )
54
+ frame.append(f" {segment:<14}", style=_BRAND_STYLE)
55
+ if with_taglines and tagline:
56
+ frame.append(tagline, style=tag_style)
57
+ frame.append("\n")
58
+ return frame
59
+
60
+
61
+ def _stroke_frames() -> list[set[tuple[int, int]]]:
62
+ """Reveal order that traces the mark like a pen: the top bar sweeps
63
+ left to right, then the two diagonals descend together to the apex."""
64
+ frames: list[set[tuple[int, int]]] = []
65
+ mask: set[tuple[int, int]] = set()
66
+ top_cells = [(0, c) for c, ch in enumerate(_LOGO[0]) if ch != " "]
67
+ for i in range(0, len(top_cells), 2):
68
+ mask.update(top_cells[i:i + 2])
69
+ frames.append(set(mask))
70
+ for r in range(1, len(_LOGO)):
71
+ mask.update((r, c) for c, ch in enumerate(_LOGO[r]) if ch != " ")
72
+ frames.append(set(mask))
73
+ return frames
74
+
75
+
76
+ def print_banner(console: Console | None = None, animate: bool = False) -> None:
77
+ console = console or Console()
78
+ if not console.is_terminal:
79
+ return
80
+ console.print()
81
+ if animate:
82
+ import time
83
+
84
+ from rich.live import Live
85
+
86
+ with Live(console=console, refresh_per_second=30, transient=False) as live:
87
+ for mask in _stroke_frames():
88
+ live.update(_compose(mask=mask, with_taglines=False))
89
+ time.sleep(0.09)
90
+ time.sleep(0.18)
91
+ live.update(_compose())
92
+ else:
93
+ console.print(_compose(), end="")
94
+ console.print()
@@ -48,8 +48,8 @@ def _build_parser() -> argparse.ArgumentParser:
48
48
  "typical flow (run from inside the target repo):\n"
49
49
  " nabla init one-time setup\n"
50
50
  " nabla scan find call sites\n"
51
- " nabla record capture traffic (inputs auto-generated if none given)\n"
52
- " nabla profile write the site profile\n"
51
+ " nabla capture intercept real traffic through a site\n"
52
+ " nabla build assemble the site profile artifact\n"
53
53
  ),
54
54
  )
55
55
  parser.add_argument("--version", action="version", version=f"nabla {__version__}")
@@ -82,14 +82,15 @@ def _build_parser() -> argparse.ArgumentParser:
82
82
  p_scan.add_argument("--json", action="store_true", help="machine-readable output")
83
83
 
84
84
  p_record = sub.add_parser(
85
- "record",
86
- help="capture (prompt, completion) pairs",
85
+ "capture",
86
+ aliases=["record"],
87
+ help="intercept (prompt, completion) pairs through a call site",
87
88
  formatter_class=argparse.RawDescriptionHelpFormatter,
88
89
  epilog=(
89
90
  "examples:\n"
90
- " nabla record auto: pick site, generate inputs\n"
91
- " nabla record --samples inputs.jsonl\n"
92
- " nabla record path/to/repo --site extract_invoice --samples inputs.jsonl\n"
91
+ " nabla capture auto: pick site, generate inputs\n"
92
+ " nabla capture --inputs inputs.jsonl\n"
93
+ " nabla capture path/to/repo --site extract_invoice --inputs inputs.jsonl\n"
93
94
  ),
94
95
  )
95
96
  p_record.add_argument("repo", nargs="?", default=".", help="path to target repo (default: current directory)")
@@ -98,24 +99,27 @@ def _build_parser() -> argparse.ArgumentParser:
98
99
  help="site symbol (auto-picked when omitted and the repo has exactly one supported call site)",
99
100
  )
100
101
  p_record.add_argument(
101
- "--samples",
102
- help="JSONL sample inputs (default: <site>.samples.jsonl if present, else auto-generated)",
102
+ "--inputs", "--samples", dest="samples",
103
+ help="JSONL inputs to run through the site (default: <site>.inputs.jsonl if present, "
104
+ "else auto-generated)",
103
105
  )
104
106
  p_record.add_argument("--logs", help="JSONL of past request/response pairs")
105
107
  p_record.add_argument("--proxy-cmd", help="command to run under the recording proxy")
106
- p_record.add_argument("--num-samples", type=int, default=5,
107
- help="how many inputs to auto-generate when no samples are given (default: 5)")
108
+ p_record.add_argument("--num-samples", type=int, default=None,
109
+ help="how many inputs to auto-generate when none are given "
110
+ "(default: asked interactively, or 5 when not a terminal)")
108
111
  p_record.add_argument("--out", default=None,
109
- help="output JSONL (default: <site>.recorded.jsonl)")
112
+ help="output JSONL (default: <site>.captured.jsonl)")
110
113
 
111
114
  p_profile = sub.add_parser(
112
- "profile",
113
- help="assemble site_profile.json",
115
+ "build",
116
+ aliases=["profile"],
117
+ help="assemble the site profile artifact",
114
118
  formatter_class=argparse.RawDescriptionHelpFormatter,
115
119
  epilog=(
116
120
  "examples:\n"
117
- " nabla profile auto: pick site, find <site>.recorded.jsonl\n"
118
- " nabla profile path/to/repo --site extract_invoice --examples recorded.jsonl\n"
121
+ " nabla build auto: pick site, find <site>.captured.jsonl\n"
122
+ " nabla build path/to/repo --site extract_invoice --captured captured.jsonl\n"
119
123
  ),
120
124
  )
121
125
  p_profile.add_argument("repo", nargs="?", default=".", help="path to target repo (default: current directory)")
@@ -123,8 +127,8 @@ def _build_parser() -> argparse.ArgumentParser:
123
127
  "--site",
124
128
  help="site symbol (auto-picked when omitted and the repo has exactly one supported call site)",
125
129
  )
126
- p_profile.add_argument("--examples",
127
- help="recorded JSONL from `nabla record` (default: <site>.recorded.jsonl)")
130
+ p_profile.add_argument("--captured", "--examples", dest="examples",
131
+ help="captured JSONL from `nabla capture` (default: <site>.captured.jsonl)")
128
132
  p_profile.add_argument("--out", default=None,
129
133
  help="output path (default: <site>.profile.json)")
130
134
 
@@ -185,21 +189,49 @@ def main(argv: list[str] | None = None) -> int:
185
189
  commands = {
186
190
  "init": _cmd_init,
187
191
  "scan": _cmd_scan,
188
- "record": _cmd_record,
189
- "profile": _cmd_profile,
192
+ "capture": _cmd_record,
193
+ "record": _cmd_record, # legacy alias
194
+ "build": _cmd_profile,
195
+ "profile": _cmd_profile, # legacy alias
190
196
  }
191
197
  return commands[args.command](args)
192
198
 
193
199
 
200
+ def _clipboard_text() -> str:
201
+ """Windows clipboard contents (CF_UNICODETEXT), or "" on any failure."""
202
+ try:
203
+ import ctypes
204
+
205
+ CF_UNICODETEXT = 13
206
+ user32, kernel32 = ctypes.windll.user32, ctypes.windll.kernel32
207
+ if not user32.OpenClipboard(0):
208
+ return ""
209
+ try:
210
+ handle = user32.GetClipboardData(CF_UNICODETEXT)
211
+ if not handle:
212
+ return ""
213
+ locked = kernel32.GlobalLock(handle)
214
+ if not locked:
215
+ return ""
216
+ try:
217
+ return ctypes.c_wchar_p(locked).value or ""
218
+ finally:
219
+ kernel32.GlobalUnlock(handle)
220
+ finally:
221
+ user32.CloseClipboard()
222
+ except Exception:
223
+ return ""
224
+
225
+
194
226
  def _read_api_key(prompt: str) -> str:
195
227
  """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."""
228
+ supported, and Ctrl+V pastes from the clipboard (classic consoles don't
229
+ translate Ctrl+V into keystrokes, which silently swallowed pastes).
230
+ Falls back to fully hidden input where character reading isn't available."""
199
231
  if sys.platform == "win32" and sys.stdin.isatty():
200
232
  import msvcrt
201
233
 
202
- print(prompt, end="", flush=True)
234
+ print(f"{prompt}(paste with Ctrl+V or right-click) ", end="", flush=True)
203
235
  chars: list[str] = []
204
236
  while True:
205
237
  ch = msvcrt.getwch()
@@ -208,7 +240,13 @@ def _read_api_key(prompt: str) -> str:
208
240
  break
209
241
  if ch == "\x03": # Ctrl+C
210
242
  raise KeyboardInterrupt
211
- if ch == "\b":
243
+ if ch == "\x16": # Ctrl+V — classic console gives us the raw control char
244
+ pasted = _clipboard_text().strip()
245
+ for c in pasted:
246
+ if c >= " ":
247
+ chars.append(c)
248
+ print("*" * len(pasted), end="", flush=True)
249
+ elif ch == "\b":
212
250
  if chars:
213
251
  chars.pop()
214
252
  print("\b \b", end="", flush=True)
@@ -222,7 +260,7 @@ def _read_api_key(prompt: str) -> str:
222
260
  def _cmd_init(args) -> int:
223
261
  from .ui import RAIL, rail, rail_end, rail_ok, rail_start, select
224
262
 
225
- print_banner(_out)
263
+ print_banner(_out, animate=True)
226
264
  rail_start(_out, "nabla init")
227
265
  rail(_out)
228
266
 
@@ -358,7 +396,7 @@ def _cmd_scan(args) -> int:
358
396
  noun = "call site" if len(rows) == 1 else "call sites"
359
397
  _out.print(f"{len(rows)} {noun} found — {supported_count} harvestable.", markup=False)
360
398
  if supported_count:
361
- _next_step("nabla record" + (f" {args.repo}" if args.repo != "." else ""))
399
+ _next_step("nabla capture" + (f" {args.repo}" if args.repo != "." else ""))
362
400
  if _unconfigured():
363
401
  _warn("tip: run `nabla init` once to set up a provider — `nabla record` "
364
402
  "and `nabla profile` need one")
@@ -370,10 +408,10 @@ def _rerun_command(args, symbol: str) -> str:
370
408
  carrying over whichever other flags were already given."""
371
409
  parts = ["nabla", args.command, args.repo, "--site", symbol]
372
410
  for flag, attr in (
373
- ("--samples", "samples"),
411
+ ("--inputs", "samples"),
374
412
  ("--logs", "logs"),
375
413
  ("--proxy-cmd", "proxy_cmd"),
376
- ("--examples", "examples"),
414
+ ("--captured", "examples"),
377
415
  ):
378
416
  value = getattr(args, attr, None)
379
417
  if value:
@@ -395,7 +433,7 @@ def _resolve_site(args, sites):
395
433
  if len(supported) == 1:
396
434
  site = supported[0]
397
435
  from .ui import rail
398
- rail(_out, f"using site {site.symbol} (the only supported call site)", style="dim")
436
+ rail(_out, f"using site {site.symbol} (the only supported call site)")
399
437
  return site, None
400
438
 
401
439
  candidates = supported or sites
@@ -417,7 +455,7 @@ def _resolve_site(args, sites):
417
455
  def _cmd_record(args) -> int:
418
456
  from .ui import make_progress, rail, rail_end, rail_ok, rail_start
419
457
 
420
- rail_start(_out, "nabla record")
458
+ rail_start(_out, "nabla capture")
421
459
  sites = scan(args.repo)
422
460
  site, err = _resolve_site(args, sites)
423
461
  if err is not None:
@@ -440,13 +478,17 @@ def _cmd_record(args) -> int:
440
478
  elif args.proxy_cmd:
441
479
  source = RecordSource(kind="proxy", proxy_command=args.proxy_cmd.split())
442
480
  else:
443
- conventional = Path(f"{site.symbol}.samples.jsonl")
444
- if conventional.exists():
445
- rail(_out, f"using {conventional} (found by convention; pass --samples to override)",
446
- style="dim")
481
+ conventional = next(
482
+ (p for p in (Path(f"{site.symbol}.inputs.jsonl"),
483
+ Path(f"{site.symbol}.samples.jsonl")) # legacy name
484
+ if p.exists()),
485
+ None,
486
+ )
487
+ if conventional is not None:
488
+ rail(_out, f"using {conventional} (found by convention; pass --inputs to override)")
447
489
  source = RecordSource(kind="samples", samples_file=str(conventional))
448
490
  else:
449
- source, err = _generate_samples_source(args, site, conventional)
491
+ source, err = _generate_samples_source(args, site, Path(f"{site.symbol}.inputs.jsonl"))
450
492
  if err is not None:
451
493
  return err
452
494
  source_kind = "generated_samples"
@@ -469,23 +511,30 @@ def _cmd_record(args) -> int:
469
511
  else:
470
512
  examples = record(site, source, args.repo)
471
513
  except ZeroTrafficError as exc:
472
- _fail(f"nabla record: {exc}")
514
+ _fail(f"nabla capture: {exc}")
515
+ return 1
516
+ except RuntimeError as exc:
517
+ message = str(exc)
518
+ _fail(f"nabla capture: {message[:400]}")
519
+ if "invalid_api_key" in message or "Invalid API Key" in message or "401" in message:
520
+ _warn("the configured provider rejected your API key — rerun `nabla init` "
521
+ "and paste it again (the input shows one * per character, including pastes)")
473
522
  return 1
474
- out = args.out or f"{site.symbol}.recorded.jsonl"
523
+ out = args.out or f"{site.symbol}.captured.jsonl"
475
524
  with open(out, "w", encoding="utf-8") as f:
476
525
  for ex in examples:
477
526
  f.write(json.dumps({**ex.to_dict(), "_source_kind": source_kind or source.kind}) + "\n")
478
527
  rail(_out)
479
- rail_ok(_out, f"recorded {len(examples)} (prompt, completion) pairs from "
528
+ rail_ok(_out, f"captured {len(examples)} (prompt, completion) pairs from "
480
529
  f"site {site.symbol} -> {out}")
481
530
  if source_kind == "generated_samples":
482
- rail(_out, f"inputs were synthetic — edit {site.symbol}.samples.jsonl with real "
531
+ rail(_out, f"inputs were synthetic — edit {site.symbol}.inputs.jsonl with real "
483
532
  "data and rerun for a higher-fidelity harvest", style="yellow")
484
533
  rail(_out)
485
534
  if args.out is None and args.site is None:
486
- rail_end(_out, f"next: nabla profile{'' if args.repo == '.' else ' ' + args.repo}")
535
+ rail_end(_out, f"next: nabla build{'' if args.repo == '.' else ' ' + args.repo}")
487
536
  else:
488
- rail_end(_out, f"next: nabla profile {args.repo} --site {site.symbol} --examples {out}")
537
+ rail_end(_out, f"next: nabla build {args.repo} --site {site.symbol} --captured {out}")
489
538
  return 0
490
539
 
491
540
 
@@ -497,14 +546,27 @@ def _generate_samples_source(args, site, out_path: Path):
497
546
  from .prompt_recon import reconstruct
498
547
  from .samplegen import SampleGenError, generate_samples
499
548
 
549
+ n = args.num_samples
550
+ if n is None:
551
+ if _out.is_terminal and sys.stdin.isatty():
552
+ from .ui import select
553
+ options = [
554
+ ("5", "quick look — fastest, fewest credits"),
555
+ ("10", "balanced — decent goal inference"),
556
+ ("20", "handoff floor — slowest, most credits"),
557
+ ]
558
+ n = int(options[select(_out, "Synthetic inputs", options)][0])
559
+ else:
560
+ n = 5
561
+
500
562
  template = reconstruct(site, args.repo)
501
563
  schema, _ = resolve_schema(site, args.repo)
502
564
  try:
503
- items = generate_samples(template, schema, args.num_samples)
565
+ items = generate_samples(template, schema, n)
504
566
  except (SampleGenError, InduceError) as exc:
505
567
  print(
506
- f"nabla record: cannot auto-generate sample inputs: {exc}\n"
507
- 'pass --samples FILE with your own inputs (JSONL, one {"input_slots": {...}} '
568
+ f"nabla capture: cannot auto-generate inputs: {exc}\n"
569
+ 'pass --inputs FILE with your own (JSONL, one {"input_slots": {...}} '
508
570
  "object per line), or run `nabla init` to configure a provider",
509
571
  file=sys.stderr,
510
572
  )
@@ -522,7 +584,7 @@ def _cmd_profile(args) -> int:
522
584
  from .schema_resolver import resolve_schema as _resolve
523
585
  from .ui import rail, rail_end, rail_ok, rail_start
524
586
 
525
- rail_start(_out, "nabla profile")
587
+ rail_start(_out, "nabla build")
526
588
  sites = scan(args.repo)
527
589
  site, err = _resolve_site(args, sites)
528
590
  if err is not None:
@@ -532,20 +594,19 @@ def _cmd_profile(args) -> int:
532
594
 
533
595
  examples_path = args.examples
534
596
  if examples_path is None:
535
- conventional = Path(f"{site.symbol}.recorded.jsonl")
536
- legacy = Path("recorded.jsonl")
537
- if conventional.exists():
538
- examples_path = str(conventional)
539
- elif legacy.exists():
540
- examples_path = str(legacy)
541
- else:
542
- record_cmd = f"nabla record {args.repo}"
597
+ candidates = [Path(f"{site.symbol}.captured.jsonl"),
598
+ Path(f"{site.symbol}.recorded.jsonl"), # legacy names
599
+ Path("recorded.jsonl")]
600
+ found = next((p for p in candidates if p.exists()), None)
601
+ if found is None:
602
+ capture_cmd = f"nabla capture {args.repo}"
543
603
  if args.site:
544
- record_cmd += f" --site {site.symbol}"
545
- print(f"nabla profile: no recorded examples found ({conventional} or recorded.jsonl). "
546
- f"Run first: {record_cmd}", file=sys.stderr)
604
+ capture_cmd += f" --site {site.symbol}"
605
+ print(f"nabla build: no captured pairs found ({candidates[0]}). "
606
+ f"Run first: {capture_cmd}", file=sys.stderr)
547
607
  return 2
548
- rail(_out, f"using {examples_path}", style="dim")
608
+ examples_path = str(found)
609
+ rail(_out, f"using {examples_path}")
549
610
 
550
611
  examples, recorded_from = [], "samples"
551
612
  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
 
@@ -460,7 +464,7 @@ def _record_samples(site: RawSite, source: RecordSource, repo_path: str,
460
464
  _run_site_in_subprocess(site, repo_path, input_slots, env)
461
465
  except (RuntimeError, subprocess.TimeoutExpired) as exc:
462
466
  failures.append((i, str(exc)))
463
- print(f"nabla record: sample {i + 1}/{total} FAILED: {str(exc)[:300]}",
467
+ print(f"nabla capture: sample {i + 1}/{total} FAILED: {str(exc)[:300]}",
464
468
  file=sys.stderr)
465
469
  continue
466
470
  with proxy._lock:
@@ -470,7 +474,7 @@ def _record_samples(site: RawSite, source: RecordSource, repo_path: str,
470
474
  if on_progress is not None:
471
475
  on_progress(i + 1, total)
472
476
  else:
473
- print(f"nabla record: sample {i + 1}/{total} -> {len(new_captures)} capture(s)",
477
+ print(f"nabla capture: sample {i + 1}/{total} -> {len(new_captures)} capture(s)",
474
478
  file=sys.stderr)
475
479
  finally:
476
480
  proxy.stop()
@@ -480,7 +484,7 @@ def _record_samples(site: RawSite, source: RecordSource, repo_path: str,
480
484
  f"all {len(failures)} sample invocations failed; first error: {failures[0][1]}"
481
485
  )
482
486
  if failures:
483
- print(f"nabla record: {len(failures)}/{total} samples failed and were skipped",
487
+ print(f"nabla capture: {len(failures)}/{total} samples failed and were skipped",
484
488
  file=sys.stderr)
485
489
  return examples
486
490
 
@@ -1,10 +1,10 @@
1
- """Synthetic sample-input generation for `nabla record` when no --samples
2
- file is given.
1
+ """Synthetic input generation for `nabla capture` when no --inputs file
2
+ is given.
3
3
 
4
4
  The site's statically recovered prompt template, slots, system prompt, and
5
5
  output schema are enough context for the configured provider to invent a
6
6
  handful of plausible inputs. The CLI always writes generated inputs to
7
- <site>.samples.jsonl so the user can inspect, edit, and rerun with real
7
+ <site>.inputs.jsonl so the user can inspect, edit, and rerun with real
8
8
  data; profiles built from them carry provenance "generated_samples",
9
9
  never "samples".
10
10
 
@@ -52,7 +52,7 @@ def generate_samples(template: PromptTemplate, schema: dict[str, Any] | None,
52
52
  if not slot_names:
53
53
  raise SampleGenError(
54
54
  "site has no reconstructable input slots — cannot generate sample "
55
- "inputs; pass --samples with your own JSONL instead"
55
+ "inputs; pass --inputs with your own JSONL instead"
56
56
  )
57
57
 
58
58
  model = model or os.environ.get("NABLA_SAMPLEGEN_MODEL") \
@@ -113,6 +113,6 @@ def _parse_samples(raw: str, slot_names: list[str], n: int) -> list[dict]:
113
113
  if not items:
114
114
  raise SampleGenError(
115
115
  "sample generation produced no items covering every slot "
116
- f"({slot_names}); pass --samples with your own JSONL instead"
116
+ f"({slot_names}); pass --inputs with your own JSONL instead"
117
117
  )
118
118
  return items
@@ -18,7 +18,7 @@ from __future__ import annotations
18
18
  import sys
19
19
 
20
20
  from rich.console import Console
21
- from rich.progress import Progress, ProgressColumn, TextColumn
21
+ from rich.progress import Progress, TextColumn
22
22
  from rich.text import Text
23
23
 
24
24
  RAIL = "│"
@@ -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
@@ -141,30 +142,18 @@ def select(console: Console, label: str, options: list[tuple[str, str]],
141
142
 
142
143
 
143
144
  # ---------------------------------------------------------------------------
144
- # Progress: the ∇ bar
145
+ # Progress
145
146
  # ---------------------------------------------------------------------------
146
147
 
147
- class NablaBar(ProgressColumn):
148
- """A bar that fills with the nabla glyph — brand as instrument."""
149
-
150
- def __init__(self, width: int = 28):
151
- super().__init__()
152
- self.width = width
153
-
154
- def render(self, task) -> Text:
155
- total = task.total or 1
156
- filled = int(self.width * min(task.completed / total, 1.0))
157
- bar = Text()
158
- bar.append("∇" * filled, style="cyan")
159
- bar.append("·" * (self.width - filled), style="dim")
160
- return bar
161
-
162
-
163
148
  def make_progress(console: Console, unit: str = "samples") -> Progress:
149
+ from rich.progress import BarColumn, SpinnerColumn, TimeElapsedColumn
150
+
164
151
  return Progress(
165
152
  TextColumn(RAIL, style="dim"),
166
- NablaBar(),
167
- TextColumn(f"{{task.completed}}/{{task.total}} {unit}", style="dim"),
153
+ SpinnerColumn(spinner_name="dots", style="cyan"),
154
+ BarColumn(bar_width=28, style="dim", complete_style="cyan", finished_style="green"),
155
+ TextColumn(f"{{task.completed}}/{{task.total}} {unit}"),
156
+ TimeElapsedColumn(),
168
157
  console=console,
169
158
  transient=True,
170
159
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nabla-cli
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -63,19 +63,19 @@ pip install nabla-cli
63
63
 
64
64
  nabla init # one-time setup, once per machine
65
65
  nabla scan
66
- nabla record
67
- nabla profile
66
+ nabla capture
67
+ nabla build
68
68
  ```
69
69
 
70
70
  No flags needed on the happy path: the repo defaults to the current
71
71
  directory, `--site` is auto-picked when the repo has exactly one
72
72
  supported call site (with more than one, pass `--site <symbol>` — the
73
- names come from `scan`'s output), `record` writes
74
- `<site>.recorded.jsonl`, and `profile` finds that file automatically by
73
+ names come from `scan`'s output), `capture` writes
74
+ `<site>.captured.jsonl`, and `build` finds that file automatically by
75
75
  the same convention.
76
76
 
77
- If you don't pass `--samples`, `record` looks for
78
- `<site>.samples.jsonl` and otherwise generates a handful of synthetic
77
+ If you don't pass `--inputs`, `capture` looks for
78
+ `<site>.inputs.jsonl` and otherwise generates a handful of synthetic
79
79
  inputs from the site's prompt template (saving them to that file so you
80
80
  can edit them and rerun with realistic data). Profiles built on
81
81
  generated inputs are marked `generated_samples` in their provenance —
@@ -110,23 +110,23 @@ extract_invoice app/invoice_extract.py create json_schema -
110
110
  1 call site found.
111
111
  ```
112
112
 
113
- ### `nabla record`
113
+ ### `nabla capture`
114
114
 
115
115
  One progress line per sample, then a summary:
116
116
 
117
117
  ```
118
- nabla record: sample 1/6 -> 1 capture(s)
119
- nabla record: sample 2/6 -> 1 capture(s)
118
+ nabla capture: sample 1/6 -> 1 capture(s)
119
+ nabla capture: sample 2/6 -> 1 capture(s)
120
120
  ...
121
- nabla record: 6 examples -> extract_invoice.recorded.jsonl
121
+ captured 6 (prompt, completion) pairs from site extract_invoice -> extract_invoice.captured.jsonl
122
122
  ```
123
123
 
124
- `--out` defaults to `<site>.recorded.jsonl` in the current directory (so
125
- `profile` can find it by the same convention afterward) — pass `--out`
124
+ `--out` defaults to `<site>.captured.jsonl` in the current directory (so
125
+ `build` can find it by the same convention afterward) — pass `--out`
126
126
  yourself only to pick a different name or location.
127
127
 
128
- To record your own inputs instead of generated ones, pass `--samples`
129
- (or create `<site>.samples.jsonl`, which is picked up automatically): a
128
+ To record your own inputs instead of generated ones, pass `--inputs`
129
+ (or create `<site>.inputs.jsonl`, which is picked up automatically): a
130
130
  JSONL file, one line per call to record. Each line is
131
131
  `{"input_slots": {...}}`, where the keys match the site function's
132
132
  keyword arguments (as reported by `scan`):
@@ -139,19 +139,19 @@ keyword arguments (as reported by `scan`):
139
139
  nabla runs the site's function once per line, in a subprocess, with its
140
140
  OpenAI client rerouted through a local recording proxy.
141
141
 
142
- ### `nabla profile`
142
+ ### `nabla build`
143
143
 
144
144
  ```
145
- nabla profile: goal — "Extract structured invoice fields (total, currency,
145
+ nabla build: goal — "Extract structured invoice fields (total, currency,
146
146
  due date) from free-form invoice or receipt text, normalizing
147
147
  locale-specific number and date formats."
148
- nabla profile: wrote extract_invoice.profile.json (site 4f2a9b1c3d8e…, supported=True, 6 examples)
148
+ nabla build: wrote extract_invoice.profile.json (site 4f2a9b1c3d8e…, supported=True, 6 examples)
149
149
  ```
150
150
 
151
151
  `--out` defaults to `<site>.profile.json` in the current directory.
152
- `--examples` is optional: `profile` looks for `<site>.recorded.jsonl` in
153
- the current directory — the file `record` just wrote — and you only need
154
- `--examples <path>` to point it at a different recording. The one LLM
152
+ `--captured` is optional: `build` looks for `<site>.captured.jsonl` in
153
+ the current directory — the file `capture` just wrote — and you only need
154
+ `--captured <path>` to point it at a different recording. The one LLM
155
155
  call in this step is goal inference: it reads the reconstructed prompt
156
156
  template, the resolved output schema, and the recorded examples, and
157
157
  returns a goal description, implicit constraints, and difficulty
@@ -165,16 +165,16 @@ for — it doesn't need to be the provider your target repo already calls.
165
165
 
166
166
  | Provider | Default model | Role |
167
167
  |---|---|---|
168
- | `openai` | your repo's own model (goal inference uses `gpt-4o-mini`) | Real upstream — `record` calls OpenAI directly with your key, auth passthrough. |
169
- | `groq` | `openai/gpt-oss-120b` | Stand-in oracle — `record` calls Groq instead of OpenAI. |
170
- | `gemini` | `gemini-3.5-flash` | Stand-in oracle — `record` calls Gemini instead of OpenAI. |
168
+ | `openai` | your repo's own model (goal inference uses `gpt-4o-mini`) | Real upstream — `capture` calls OpenAI directly with your key, auth passthrough. |
169
+ | `groq` | `openai/gpt-oss-120b` | Stand-in oracle — `capture` calls Groq instead of OpenAI. |
170
+ | `gemini` | `gemini-3.5-flash` | Stand-in oracle — `capture` calls Gemini instead of OpenAI. |
171
171
 
172
172
  When you pick `groq` or `gemini`, they only stand in for *generating* the
173
- completions during `record`. The profile's cost/latency baseline always
173
+ completions during `capture`. The profile's cost/latency baseline always
174
174
  reports numbers for the target repo's own model — the one your code
175
175
  actually calls in production — never the stand-in's.
176
176
 
177
- Gemini's free tier has a very low daily quota: expect `record` to pace
177
+ Gemini's free tier has a very low daily quota: expect `capture` to pace
178
178
  requests with multi-second delays and to still hit rate limits past a
179
179
  handful of samples. `groq` or `openai` handle larger sample sets more
180
180
  comfortably.
@@ -198,11 +198,11 @@ work and take priority over it:
198
198
 
199
199
  - `--site <symbol>` — skip site auto-pick; required once a repo has more
200
200
  than one supported call site.
201
- - `--out <path>` (`record`) — write recorded examples somewhere other than
202
- `<site>.recorded.jsonl`.
203
- - `--examples <path>` (`profile`) — read recorded examples from somewhere
204
- other than `<site>.recorded.jsonl`.
205
- - `--out <path>` (`profile`) — write the profile somewhere other than
201
+ - `--out <path>` (`capture`) — write recorded examples somewhere other than
202
+ `<site>.captured.jsonl`.
203
+ - `--captured <path>` (`build`) — read recorded examples from somewhere
204
+ other than `<site>.captured.jsonl`.
205
+ - `--out <path>` (`build`) — write the profile somewhere other than
206
206
  `<site>.profile.json`.
207
207
 
208
208
  ### Environment variables
@@ -216,7 +216,7 @@ highest first:
216
216
 
217
217
  | Variable | Purpose |
218
218
  |---|---|
219
- | `OPENAI_API_KEY` | Default upstream for `record` (auth passthrough). |
219
+ | `OPENAI_API_KEY` | Default upstream for `capture` (auth passthrough). |
220
220
  | `GROQ_API_KEY` / `GEMINI_API_KEY` | That provider's conventional key env, read if `~/.nabla/config.json` has no stored key. |
221
221
  | `NABLA_PROVIDER` | Overrides the provider `nabla init` selected. |
222
222
  | `NABLA_UPSTREAM_BASE_URL` | Record through any OpenAI-compatible stand-in oracle instead. |
@@ -228,7 +228,7 @@ highest first:
228
228
 
229
229
  ## Notes
230
230
 
231
- - `record` runs the site's enclosing function in a subprocess per sample,
231
+ - `capture` runs the site's enclosing function in a subprocess per sample,
232
232
  with `OPENAI_BASE_URL` pointed at a local recording proxy. If the
233
233
  target's tests mock the SDK, the proxy path reports zero traffic
234
234
  explicitly instead of writing an empty profile.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nabla-cli"
3
- version = "0.4.0"
3
+ version = "0.5.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" }
@@ -123,7 +123,7 @@ def test_record_no_source_generation_failure_mentions_samples(monkeypatch, capsy
123
123
 
124
124
  assert rc == 1
125
125
  err = capsys.readouterr().err
126
- assert "--samples" in err
126
+ assert "--inputs" in err
127
127
  assert "provider unavailable" in err
128
128
 
129
129
 
@@ -179,7 +179,7 @@ def test_record_help_includes_examples(capsys):
179
179
  assert exc.value.code == 0
180
180
  out = capsys.readouterr().out
181
181
  assert "examples:" in out
182
- assert "nabla record" in out
182
+ assert "nabla capture" in out
183
183
 
184
184
 
185
185
  # ---------------------------------------------------------------------------
@@ -203,10 +203,10 @@ def test_record_auto_names_output_by_symbol(tmp_path, monkeypatch, capsys):
203
203
  rc = main(["record", str(repo), "--samples", str(samples)])
204
204
 
205
205
  assert rc == 0
206
- out_file = tmp_path / "extract_receipt.recorded.jsonl"
206
+ out_file = tmp_path / "extract_receipt.captured.jsonl"
207
207
  assert out_file.exists()
208
208
  stdout = capsys.readouterr().out
209
- assert "extract_receipt.recorded.jsonl" in stdout
209
+ assert "extract_receipt.captured.jsonl" in stdout
210
210
 
211
211
 
212
212
  def test_record_generates_samples_when_none_given(tmp_path, monkeypatch, capsys):
@@ -223,16 +223,16 @@ def test_record_generates_samples_when_none_given(tmp_path, monkeypatch, capsys)
223
223
  rc = main(["record", str(repo), "--num-samples", "3"])
224
224
 
225
225
  assert rc == 0
226
- samples_file = tmp_path / "extract_receipt.samples.jsonl"
226
+ samples_file = tmp_path / "extract_receipt.inputs.jsonl"
227
227
  assert samples_file.exists()
228
228
  assert len(samples_file.read_text(encoding="utf-8").splitlines()) == 3
229
- recorded = tmp_path / "extract_receipt.recorded.jsonl"
229
+ recorded = tmp_path / "extract_receipt.captured.jsonl"
230
230
  lines = [json.loads(x) for x in recorded.read_text(encoding="utf-8").splitlines()]
231
231
  assert len(lines) == 3
232
232
  assert all(line["_source_kind"] == "generated_samples" for line in lines)
233
233
  stdout = capsys.readouterr().out
234
234
  assert "generated 3 synthetic inputs" in stdout
235
- assert "next: nabla profile" in stdout
235
+ assert "next: nabla build" in stdout
236
236
 
237
237
 
238
238
  def test_record_prefers_conventional_samples_file(tmp_path, monkeypatch, capsys):
@@ -253,7 +253,7 @@ def test_record_prefers_conventional_samples_file(tmp_path, monkeypatch, capsys)
253
253
  stdout = capsys.readouterr().out
254
254
  assert "found by convention" in stdout
255
255
  lines = [json.loads(x) for x in
256
- (tmp_path / "extract_receipt.recorded.jsonl").read_text(encoding="utf-8").splitlines()]
256
+ (tmp_path / "extract_receipt.captured.jsonl").read_text(encoding="utf-8").splitlines()]
257
257
  assert lines and all(line["_source_kind"] == "samples" for line in lines)
258
258
 
259
259
 
@@ -293,7 +293,7 @@ def test_profile_without_recorded_file_exits_2_with_record_command(tmp_path, mon
293
293
 
294
294
  assert rc == 2
295
295
  err = capsys.readouterr().err
296
- assert "nabla record" in err
296
+ assert "nabla capture" in err
297
297
 
298
298
 
299
299
  def test_bare_scan_defaults_to_cwd(tmp_path, monkeypatch, capsys):
@@ -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