inquirex-tty 0.10.0 → 0.12.1
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.
- checksums.yaml +4 -4
- data/.circleci/config.yml +57 -0
- data/.envrc +2 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +28 -12
- data/README.md +62 -12
- data/docs/badges/coverage_badge.svg +2 -2
- data/examples/08_tax_preparer.rb +2 -2
- data/examples/10_answers.json +1 -1
- data/lib/inquirex/tty/answers_file.rb +48 -0
- data/lib/inquirex/tty/commands/run.rb +106 -50
- data/lib/inquirex/tty/flow_loader.rb +1 -2
- data/lib/inquirex/tty/key_map.rb +127 -0
- data/lib/inquirex/tty/renderer.rb +180 -10
- data/lib/inquirex/tty/ui_helper.rb +3 -2
- data/lib/inquirex/tty/version.rb +1 -1
- data/lib/inquirex/tty.rb +2 -0
- metadata +213 -191
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ef0bce06b980c6e167a092cff784d1f63f2eea80d72b78384841455baac1865c
|
|
4
|
+
data.tar.gz: 87306ae23bf1cb5472d8913970b08c26e420cc1ee5ae3659974195aa0639c97c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 288b6c184460ea72e6e75df023ee6840ebc4a805eb47607c2e0735235f02d43842be5526219dde5a0caae18668017c87430d51995f412e55458b938dd69b1abe
|
|
7
|
+
data.tar.gz: a178284089b5f66e270d6f9aa4c940be8445edd9d09b2b04ac7b8cec5c132b637ec3575567423c609cde82cddb13af9fe1575512dd7c84d9806c299d826c5b32
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
version: 2.1
|
|
2
|
+
|
|
3
|
+
executors:
|
|
4
|
+
# Pure-Ruby gem: no database, no browser, no native extensions.
|
|
5
|
+
ruby:
|
|
6
|
+
docker:
|
|
7
|
+
- image: cimg/ruby:4.0.6
|
|
8
|
+
working_directory: ~/inquirex-tty
|
|
9
|
+
|
|
10
|
+
commands:
|
|
11
|
+
sources:
|
|
12
|
+
description: "Check out and install gems against the image's Ruby"
|
|
13
|
+
steps:
|
|
14
|
+
- checkout
|
|
15
|
+
- run:
|
|
16
|
+
# .ruby-version pins 4.1.0-preview1, which has no cimg image yet.
|
|
17
|
+
name: Switch Ruby version
|
|
18
|
+
command: echo '4.0.6' > .ruby-version
|
|
19
|
+
- run:
|
|
20
|
+
name: Install gems
|
|
21
|
+
command: |
|
|
22
|
+
bundle update --bundler
|
|
23
|
+
bundle install -j 4
|
|
24
|
+
|
|
25
|
+
jobs:
|
|
26
|
+
scan_ruby:
|
|
27
|
+
executor: ruby
|
|
28
|
+
steps:
|
|
29
|
+
- sources
|
|
30
|
+
- run:
|
|
31
|
+
name: Scan Ruby dependencies for known CVEs
|
|
32
|
+
command: |
|
|
33
|
+
bundle exec bundle-audit update --quiet
|
|
34
|
+
bundle exec bundle-audit check
|
|
35
|
+
|
|
36
|
+
rubocop:
|
|
37
|
+
executor: ruby
|
|
38
|
+
steps:
|
|
39
|
+
- sources
|
|
40
|
+
- run:
|
|
41
|
+
name: Lint code for consistent style
|
|
42
|
+
command: bundle exec rubocop
|
|
43
|
+
|
|
44
|
+
test:
|
|
45
|
+
executor: ruby
|
|
46
|
+
steps:
|
|
47
|
+
- sources
|
|
48
|
+
- run:
|
|
49
|
+
name: Run tests
|
|
50
|
+
command: bundle exec rspec
|
|
51
|
+
|
|
52
|
+
workflows:
|
|
53
|
+
ci:
|
|
54
|
+
jobs:
|
|
55
|
+
- scan_ruby
|
|
56
|
+
- rubocop
|
|
57
|
+
- test
|
data/.envrc
CHANGED
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4.1.0-preview1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.12.0 (unreleased)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Hand-written until `github_changelog_generator` regenerates this file at release. The family-wide notes are in inquirex's `INQUIREX_VERSION_LOG.md`.
|
|
6
|
+
|
|
7
|
+
- **BREAKING: results go to stdout.** `run` printed its JSON results on stderr; it now prints them on stdout, and draws every prompt, box and progress line on stderr, so `inqt run flow.rb > answers.json` captures a clean document. A caller that read the results from stderr must read stdout. `-o FILE` still writes a file (and now leaves stdout empty); `-o -` names stdout.
|
|
8
|
+
- **Going back, skipping and a keymap.** At any question after the first, Backspace on an empty answer (or anywhere in a list) or Ctrl-B returns to the previous question with the earlier answer as its default (`Engine#back`, `#can_back?`, `#previous_answer` from inquirex 0.12). Ctrl-S skips an optional question (`Engine#skip`) and is refused on a required one. `?` on an empty answer shows the keymap. A hint line above each question lists the keys. Display and LLM steps in between are stepped over. Cmd-Backspace was considered and rejected: terminals do not send it to a program unless the user maps it by hand.
|
|
9
|
+
- **Ctrl-C aborts cleanly** with exit status 130 and a one-line message, instead of a stack trace.
|
|
10
|
+
- **Headless runs: `run --answers FILE` and `--yes`.** Preset answers (`{step_id: value}`, JSON or YAML by extension) are fed to the engine through `Inquirex::Headless`; no prompt or renderer is built, standard input is never read, no LLM adapter is called, and the public-IP probe is skipped. `--yes` answers the remaining steps with their defaults. A required step left without an answer exits 1 and names the step on stderr.
|
|
11
|
+
- **Adapter selection comes from inquirex-llm.** `run` builds its LLM adapter with `Inquirex::LLM.adapter`, re-reading inquirex-llm's configuration after `.env` loads. The documented `INQUIREX_LLM_ADAPTER` / `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` order is unchanged, and the default models and limits now follow inquirex-llm's `INQUIREX_LLM_*` variables. An unknown `INQUIREX_LLM_ADAPTER` value now raises an error naming the variable instead of falling through to auto-detection.
|
|
12
|
+
- Step numbers in the progress line start at 1 again (the first step printed as "Step 2").
|
|
13
|
+
- **Requires `inquirex ~> 0.12` and `inquirex-llm ~> 0.12`** (locked at 0.12.1; the `openrouter` and `jev` adapters it adds are selectable through `INQUIREX_LLM_ADAPTER`). `irb` is no longer in the Gemfile: through rdoc it pulled in `erb`, a default gem that a local bundle locks with an empty checksum and a frozen CI install then rejects. 0.10.0 still required `~> 0.9.5` of both, so it could not be installed beside the 0.10.0 core.
|
|
14
|
+
- README samples use a fictional person (Alan Turing, `alan.turing@manchester.edu`), as does `examples/10_answers.json`.
|
|
15
|
+
- Ruby 4.1.0-preview1: the suite passes with Bundler 4.0.16; Gemfile.lock moves off Bundler 4.0.10, which fails on 4.1 with `uninitialized constant Pathname::SEPARATOR_PAT`. `spec/inquirex/tty/gemspec_spec.rb` fails when `lib/` requires a library that neither ships with the running Ruby nor is a declared runtime dependency.
|
|
16
|
+
- CircleCI config copied from inquirex core; `.ruby-version` pins 4.1.0-preview1.
|
|
17
|
+
|
|
18
|
+
## [v0.10.0](https://github.com/inquirex/inquirex-tty/tree/v0.10.0) (2026-08-21)
|
|
19
|
+
|
|
20
|
+
[Full Changelog](https://github.com/inquirex/inquirex-tty/compare/v0.6.0...v0.10.0)
|
|
6
21
|
|
|
7
22
|
**Merged pull requests:**
|
|
8
23
|
|
|
9
|
-
-
|
|
24
|
+
- Set the lockstep version to 0.10.0 [#12](https://github.com/inquirex/inquirex-tty/pull/12) ([kigster](https://github.com/kigster))
|
|
25
|
+
- Bump to 0.9.6 [#11](https://github.com/inquirex/inquirex-tty/pull/11) ([kigster](https://github.com/kigster))
|
|
26
|
+
- Pre-check LLM suggestions, add the inqt alias, join the 0.9.2 lockstep [#10](https://github.com/inquirex/inquirex-tty/pull/10) ([kigster](https://github.com/kigster))
|
|
27
|
+
- Fix CI: broken Gemfile path override, RuboCop scanning vendor/ [#9](https://github.com/inquirex/inquirex-tty/pull/9) ([kigster](https://github.com/kigster))
|
|
10
28
|
|
|
11
29
|
## [v0.6.0](https://github.com/inquirex/inquirex-tty/tree/v0.6.0) (2026-07-20)
|
|
12
30
|
|
|
@@ -14,8 +32,8 @@
|
|
|
14
32
|
|
|
15
33
|
**Merged pull requests:**
|
|
16
34
|
|
|
17
|
-
- Document lib in YARD style to 100% coverage [
|
|
18
|
-
- Use $stderr.puts for CLI messages, not warn [
|
|
35
|
+
- Document lib in YARD style to 100% coverage [#8](https://github.com/inquirex/inquirex-tty/pull/8) ([kigster](https://github.com/kigster))
|
|
36
|
+
- Use $stderr.puts for CLI messages, not warn [#7](https://github.com/inquirex/inquirex-tty/pull/7) ([kigster](https://github.com/kigster))
|
|
19
37
|
|
|
20
38
|
## [v0.5.0](https://github.com/inquirex/inquirex-tty/tree/v0.5.0) (2026-07-16)
|
|
21
39
|
|
|
@@ -27,12 +45,10 @@
|
|
|
27
45
|
|
|
28
46
|
**Merged pull requests:**
|
|
29
47
|
|
|
30
|
-
- Raise inquirex-tty test coverage from 79% to 99% [
|
|
31
|
-
- Update bundled dependencies [
|
|
32
|
-
- Integrate inquirex-llm, drop inquirex-ui dep, add LLM-assisted tax example [
|
|
33
|
-
- Add inquirex export command and shared OutputPath helper [
|
|
34
|
-
- Simplify graph command: merge open-graph, add "both" format [
|
|
35
|
-
|
|
36
|
-
|
|
48
|
+
- Raise inquirex-tty test coverage from 79% to 99% [#5](https://github.com/inquirex/inquirex-tty/pull/5) ([kigster](https://github.com/kigster))
|
|
49
|
+
- Update bundled dependencies [#4](https://github.com/inquirex/inquirex-tty/pull/4) ([kigster](https://github.com/kigster))
|
|
50
|
+
- Integrate inquirex-llm, drop inquirex-ui dep, add LLM-assisted tax example [#3](https://github.com/inquirex/inquirex-tty/pull/3) ([kigster](https://github.com/kigster))
|
|
51
|
+
- Add inquirex export command and shared OutputPath helper [#2](https://github.com/inquirex/inquirex-tty/pull/2) ([kigster](https://github.com/kigster))
|
|
52
|
+
- Simplify graph command: merge open-graph, add "both" format [#1](https://github.com/inquirex/inquirex-tty/pull/1) ([kigster](https://github.com/kigster))
|
|
37
53
|
|
|
38
54
|
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*
|
data/README.md
CHANGED
|
@@ -63,19 +63,61 @@ The CLI walks the user through each step, selecting the appropriate TTY widget f
|
|
|
63
63
|
Execute a flow interactively. Each step is rendered with the appropriate tty-prompt widget based on the node's data type and widget hints.
|
|
64
64
|
|
|
65
65
|
```bash
|
|
66
|
-
# run it
|
|
66
|
+
# run it; the answers are printed as JSON on stdout
|
|
67
67
|
inqt run examples/08_tax_preparer.rb
|
|
68
68
|
|
|
69
|
-
#
|
|
70
|
-
inqt run examples/08_tax_preparer.rb
|
|
71
|
-
|
|
69
|
+
# the prompts are drawn on stderr, so stdout can be redirected
|
|
70
|
+
inqt run examples/08_tax_preparer.rb > answers.json
|
|
71
|
+
|
|
72
|
+
# or save the answers to a file
|
|
73
|
+
inqt run examples/08_tax_preparer.rb --output answers.json
|
|
72
74
|
```
|
|
73
75
|
|
|
74
76
|
Options:
|
|
75
77
|
|
|
76
|
-
| Flag | Description
|
|
77
|
-
| ---------------- |
|
|
78
|
-
| `--output`, `-o` | Write JSON results to
|
|
78
|
+
| Flag | Description |
|
|
79
|
+
| ---------------- | ---------------------------------------------------------------------------------- |
|
|
80
|
+
| `--output`, `-o` | Write the JSON results to this file; `-o -` (or no `-o`) writes them to stdout |
|
|
81
|
+
| `--answers FILE` | Run headless from preset answers, `{step_id: value}` in `.json`, `.yml` or `.yaml` |
|
|
82
|
+
| `--yes`, `-y` | Run headless, answering every step the answers file leaves out with its default |
|
|
83
|
+
|
|
84
|
+
**Results channel.** The results JSON goes to stdout, and only the results: every prompt, box and progress line of an interactive run is drawn on stderr. `-o FILE` writes the results to the file instead and leaves stdout empty. Until 0.12.0 the results went to stderr.
|
|
85
|
+
|
|
86
|
+
**Keys.** A dim hint line above each question lists the navigation keys (`⌫ back · ^S skip · ? keys · ^C abort`); press `?` for the full keymap.
|
|
87
|
+
|
|
88
|
+
| Key | Action |
|
|
89
|
+
| --------------- | ----------------------------------------------------------------------- |
|
|
90
|
+
| `Enter` | submit the answer; an empty answer keeps the one shown in parentheses |
|
|
91
|
+
| `Backspace` | go back to the previous question (on an empty answer; always in a list) |
|
|
92
|
+
| `Ctrl-B` | go back to the previous question |
|
|
93
|
+
| `Ctrl-S` | skip the question (optional questions only) |
|
|
94
|
+
| `↑` `↓` `Space` | move and toggle in a list |
|
|
95
|
+
| `?` | show the keymap (on an empty answer; always in a list) |
|
|
96
|
+
| `Ctrl-C` | abort without saving (exit status 130) |
|
|
97
|
+
|
|
98
|
+
**Going back** returns to the previous question with your earlier answer filled in as the default. Display steps and LLM steps in between are stepped over. On the first question the back keys do nothing, and `Backspace` with text typed simply deletes a character.
|
|
99
|
+
|
|
100
|
+
**Headless runs.** With `--answers` or `--yes` the flow runs without a terminal: no prompt is drawn, standard input is never read, and no LLM adapter is called, which makes it the way to run a flow in tests and CI.
|
|
101
|
+
|
|
102
|
+
```yaml
|
|
103
|
+
# answers.yml
|
|
104
|
+
filing_status: single
|
|
105
|
+
income_types: [W2]
|
|
106
|
+
state_filing: [California]
|
|
107
|
+
foreign_accounts: "no"
|
|
108
|
+
deduction_types: [None]
|
|
109
|
+
client_name: Alan Turing
|
|
110
|
+
client_email: alan.turing@manchester.edu
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# dependents is left out: --yes answers it with its default, 0
|
|
115
|
+
inqt run examples/08_tax_preparer.rb --answers answers.yml --yes > results.json
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- Answers are passed to the engine as written: a multi-select takes a list of option values, not their labels.
|
|
119
|
+
- A step the file leaves out is skipped when it is optional. With `--yes` a step with a default takes it, and an LLM step with none is answered with `null`.
|
|
120
|
+
- A required step with no answer stops the run: it exits 1 and names the step on stderr.
|
|
79
121
|
|
|
80
122
|
On completion, outputs a JSON summary:
|
|
81
123
|
|
|
@@ -94,8 +136,8 @@ On completion, outputs a JSON summary:
|
|
|
94
136
|
"state_filing": ["California"],
|
|
95
137
|
"foreign_accounts": "no",
|
|
96
138
|
"deduction_types": ["Medical"],
|
|
97
|
-
"client_name": "
|
|
98
|
-
"client_email": "
|
|
139
|
+
"client_name": "Alan Turing",
|
|
140
|
+
"client_email": "alan.turing@manchester.edu"
|
|
99
141
|
},
|
|
100
142
|
"steps_completed": 10,
|
|
101
143
|
"completed_at": "2026-04-13T23:51:33-07:00"
|
|
@@ -214,10 +256,10 @@ Step 8: deduction_types
|
|
|
214
256
|
Which additional deductions apply to you? Medical
|
|
215
257
|
Step 9: client_name
|
|
216
258
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
217
|
-
Your name, please:
|
|
259
|
+
Your name, please: Alan Turing
|
|
218
260
|
Step 10: client_email
|
|
219
261
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
220
|
-
Your email address:
|
|
262
|
+
Your email address: alan.turing@manchester.edu
|
|
221
263
|
Step 11: thanks
|
|
222
264
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
223
265
|
|
|
@@ -284,6 +326,8 @@ When a flow contains [inquirex-llm](../inquirex-llm) verbs (`clarify`, `describe
|
|
|
284
326
|
| `OPENAI_API_KEY` is set | `Inquirex::LLM::OpenAIAdapter` |
|
|
285
327
|
| nothing set | `Inquirex::LLM::NullAdapter` (demo) |
|
|
286
328
|
|
|
329
|
+
The selection is `Inquirex::LLM.adapter`, from inquirex-llm's configuration, so the default models and limits follow its environment variables too (`INQUIREX_LLM_ANTHROPIC_MODEL`, `INQUIREX_LLM_OPENAI_MODEL`, `INQUIREX_LLM_MAX_TOKENS` and the rest; see inquirex-llm's README, "Configuration"). An unknown `INQUIREX_LLM_ADAPTER` value now stops the run with an error naming the variable, instead of falling through to auto-detection.
|
|
330
|
+
|
|
287
331
|
### End-to-end example
|
|
288
332
|
|
|
289
333
|
`examples/09_tax_preparer_llm.rb` is a complete LLM-assisted tax intake. The user types one free-text description of their tax situation; the LLM extracts `filing_status`, `dependents`, `income_types`, `state_filing`; the wizard only asks for whatever the LLM couldn't determine, then runs a final `summarize` for a complexity / fee-estimate write-up.
|
|
@@ -422,7 +466,9 @@ inquirex-tty/
|
|
|
422
466
|
│ ├── graph.rb # Mermaid diagram export
|
|
423
467
|
│ ├── export.rb # JSON / YAML serialization
|
|
424
468
|
│ └── version.rb # Print version info
|
|
425
|
-
├── renderer.rb # Node → tty-prompt widget dispatcher
|
|
469
|
+
├── renderer.rb # Node → tty-prompt widget dispatcher, back/skip/help
|
|
470
|
+
├── key_map.rb # Navigation keys and the exception that unwinds a prompt
|
|
471
|
+
├── answers_file.rb # --answers: JSON or YAML preset answers
|
|
426
472
|
├── flow_loader.rb # Load .rb flow definitions
|
|
427
473
|
├── output_path.rb # Shared -o/--output path resolution
|
|
428
474
|
├── ui_helper.rb # TTY::Box/Pastel/Screen helpers
|
|
@@ -433,6 +479,8 @@ inquirex-tty/
|
|
|
433
479
|
|
|
434
480
|
`Inquirex::TTY::Renderer` is the core class that maps nodes to tty-prompt calls. It reads the `:tty` widget hint from each node (via `WidgetRegistry` defaults or explicit DSL hints) and dispatches to the matching `render_*` method.
|
|
435
481
|
|
|
482
|
+
`Renderer#collect(engine)` asks the engine's current step and answers it, unless the user presses a navigation key: back calls `Engine#back` and returns `:back` (the run loop then asks the previous question again with `Engine#previous_answer` as its default), skip calls `Engine#skip` on an optional step and returns `:skipped` (`:stay` on a required one), and `?` shows the keymap and returns `:help`. `Inquirex::TTY::KeyMap`, a tty-reader listener, raises `Inquirex::TTY::Navigation` for the key, which unwinds whichever tty-prompt widget is open.
|
|
483
|
+
|
|
436
484
|
The `TTY::Prompt` instance is injectable for testing:
|
|
437
485
|
|
|
438
486
|
```ruby
|
|
@@ -450,6 +498,8 @@ A mixin module providing styled output helpers (`box`, `info`, `success`, `error
|
|
|
450
498
|
|
|
451
499
|
## Development
|
|
452
500
|
|
|
501
|
+
Ruby 4.0 or later. `.ruby-version` pins 4.1.0-preview1, where the suite passes with Bundler 4.0.16 (Bundler 4.0.10 fails on 4.1 with `uninitialized constant Pathname::SEPARATOR_PAT` before any spec runs). CI runs on the CircleCI image for Ruby 4.0.6.
|
|
502
|
+
|
|
453
503
|
```bash
|
|
454
504
|
bin/setup # install dependencies
|
|
455
505
|
bundle exec rspec # run tests
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
|
16
16
|
<text x="31.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
|
|
17
17
|
<text x="31.5" y="14">coverage</text>
|
|
18
|
-
<text x="80" y="15" fill="#010101" fill-opacity=".3">
|
|
19
|
-
<text x="80" y="14">
|
|
18
|
+
<text x="80" y="15" fill="#010101" fill-opacity=".3">97%</text>
|
|
19
|
+
<text x="80" y="14">97%</text>
|
|
20
20
|
</g>
|
|
21
21
|
</svg>
|
data/examples/08_tax_preparer.rb
CHANGED
|
@@ -98,8 +98,8 @@ Inquirex.define id: "tax-preparer-2025", version: "1.0.0" do
|
|
|
98
98
|
options %w[Stocks Bonds Crypto RealEstate MutualFunds]
|
|
99
99
|
widget target: :tty, type: :multi_select
|
|
100
100
|
widget target: :desktop, type: :checkbox_group
|
|
101
|
-
transition to: :crypto_details,
|
|
102
|
-
transition to: :rental_details,
|
|
101
|
+
transition to: :crypto_details, if_rule: contains(:investment_details, "Crypto")
|
|
102
|
+
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
|
|
103
103
|
transition to: :state_filing
|
|
104
104
|
end
|
|
105
105
|
|
data/examples/10_answers.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module TTY
|
|
5
|
+
# Reads the preset answers for `inquirex-tty run --answers FILE`.
|
|
6
|
+
#
|
|
7
|
+
# The file maps step ids to answers, `{step_id: value}`, in JSON or YAML;
|
|
8
|
+
# the extension decides which. Values are passed to the engine as written:
|
|
9
|
+
# a multi-select takes a list of option values, not their labels.
|
|
10
|
+
#
|
|
11
|
+
# @example answers.yml
|
|
12
|
+
# income_types: [w2, rental]
|
|
13
|
+
# client_name: Alan Turing
|
|
14
|
+
# client_email: alan.turing@manchester.edu
|
|
15
|
+
module AnswersFile
|
|
16
|
+
# @return [Hash{String => Symbol}] extension to format
|
|
17
|
+
FORMATS = { ".json" => :json, ".yml" => :yaml, ".yaml" => :yaml }.freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# @param path [String]
|
|
22
|
+
# @return [Hash{String => Object}] answers keyed by step id
|
|
23
|
+
# @raise [Inquirex::TTY::Error] when the file is missing, has another
|
|
24
|
+
# extension, does not parse, or is not a mapping
|
|
25
|
+
def load(path)
|
|
26
|
+
format = FORMATS[File.extname(path).downcase]
|
|
27
|
+
raise Error, "Answers file must be .json, .yml or .yaml: #{path}" unless format
|
|
28
|
+
raise Error, "Answers file not found: #{path}" unless File.file?(path)
|
|
29
|
+
|
|
30
|
+
answers = parse(File.read(path), format)
|
|
31
|
+
raise Error, "Answers file must map step ids to answers: #{path}" unless answers.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
answers
|
|
34
|
+
rescue JSON::ParserError, Psych::Exception => e
|
|
35
|
+
raise Error, "Cannot parse answers file #{path}: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# safe_load: an answers file is data, so no YAML tags that build objects.
|
|
39
|
+
#
|
|
40
|
+
# @param text [String]
|
|
41
|
+
# @param format [Symbol] :json or :yaml
|
|
42
|
+
# @return [Object]
|
|
43
|
+
def parse(text, format)
|
|
44
|
+
format == :json ? JSON.parse(text) : YAML.safe_load(text)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -3,37 +3,101 @@
|
|
|
3
3
|
module Inquirex
|
|
4
4
|
module TTY
|
|
5
5
|
module Commands
|
|
6
|
-
# Runs a flow definition
|
|
7
|
-
#
|
|
6
|
+
# Runs a flow definition and writes the collected answers (plus
|
|
7
|
+
# metadata) as JSON to stdout, or to the file named by -o.
|
|
8
|
+
#
|
|
9
|
+
# Interactively, every prompt, box and progress line goes to stderr, so
|
|
10
|
+
# stdout carries the results and nothing else: `inquirex-tty run flow.rb
|
|
11
|
+
# > answers.json` captures a clean document. With --answers or --yes the
|
|
12
|
+
# run is headless: Inquirex::Headless feeds the engine, and no prompt,
|
|
13
|
+
# renderer or LLM adapter is ever built, so it needs no TTY.
|
|
8
14
|
class Run < Dry::CLI::Command
|
|
9
|
-
desc "Run a flow definition interactively"
|
|
15
|
+
desc "Run a flow definition interactively, or headless from an answers file"
|
|
10
16
|
|
|
11
17
|
argument :flow_file, required: true, desc: "Path to flow definition (.rb file)"
|
|
12
|
-
option :
|
|
18
|
+
option :answers, desc: "Run headless with these answers ({step_id: value}, .json, .yml or .yaml)"
|
|
19
|
+
option :yes,
|
|
20
|
+
type: :boolean,
|
|
21
|
+
default: false,
|
|
22
|
+
aliases: ["-y"],
|
|
23
|
+
desc: "Run headless, taking each unanswered step's default"
|
|
24
|
+
option :output, aliases: ["-o"], desc: "Write JSON results to this file (- for stdout, the default)"
|
|
13
25
|
|
|
14
26
|
# @param flow_file [String]
|
|
15
|
-
# @param options [Hash]
|
|
27
|
+
# @param options [Hash] :answers, :yes, :output
|
|
16
28
|
# @return [void]
|
|
17
29
|
def call(flow_file:, **options)
|
|
18
|
-
|
|
19
|
-
json
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
puts "\nResults saved to #{options[:output]}"
|
|
24
|
-
else
|
|
25
|
-
$stderr.puts json
|
|
26
|
-
end
|
|
30
|
+
json = on_stderr { JSON.pretty_generate(results_for(flow_file, **options)) }
|
|
31
|
+
write_results(json, options[:output])
|
|
32
|
+
rescue ::TTY::Reader::InputInterrupt
|
|
33
|
+
on_stderr { error("Aborted — no answers were saved.") }
|
|
34
|
+
exit 130
|
|
27
35
|
rescue Inquirex::TTY::Error => e
|
|
28
|
-
error(e.message)
|
|
36
|
+
on_stderr { error(e.message) }
|
|
29
37
|
exit 1
|
|
30
38
|
rescue Inquirex::Errors::Error => e
|
|
31
|
-
error("Engine error: #{e.message}")
|
|
39
|
+
on_stderr { error("Engine error: #{e.message}") }
|
|
32
40
|
exit 1
|
|
33
41
|
end
|
|
34
42
|
|
|
35
43
|
private
|
|
36
44
|
|
|
45
|
+
# @param flow_file [String]
|
|
46
|
+
# @param answers [String, nil] answers file path
|
|
47
|
+
# @param yes [Boolean]
|
|
48
|
+
# @return [Hash]
|
|
49
|
+
def results_for(flow_file, answers: nil, yes: false, **)
|
|
50
|
+
if answers || yes
|
|
51
|
+
build_result(flow_file, run_headless(flow_file, answers, yes))
|
|
52
|
+
else
|
|
53
|
+
engine = run_flow(flow_file)
|
|
54
|
+
build_result(flow_file,
|
|
55
|
+
path_taken: engine.history,
|
|
56
|
+
answers: engine.answers_with_metadata,
|
|
57
|
+
steps_completed: engine.history.length)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Points $stdout at stderr for the block, so every `puts` and every
|
|
62
|
+
# TTY::Prompt built inside it (its output defaults to $stdout when it
|
|
63
|
+
# is created) write to stderr.
|
|
64
|
+
#
|
|
65
|
+
# @return [Object] the block's value
|
|
66
|
+
def on_stderr
|
|
67
|
+
original = $stdout
|
|
68
|
+
$stdout = $stderr
|
|
69
|
+
yield
|
|
70
|
+
ensure
|
|
71
|
+
$stdout = original
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @param json [String]
|
|
75
|
+
# @param output [String, nil] a path, "-" or nil for stdout
|
|
76
|
+
# @return [void]
|
|
77
|
+
def write_results(json, output)
|
|
78
|
+
if output.nil? || output == "-"
|
|
79
|
+
$stdout.puts json
|
|
80
|
+
else
|
|
81
|
+
File.write(output, "#{json}\n")
|
|
82
|
+
warn "Results saved to #{output}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Runs the flow with no TTY. Completion metadata is gathered without
|
|
87
|
+
# the public-IP probe: a headless run is for tests and CI, and should
|
|
88
|
+
# neither wait on nor reach the network.
|
|
89
|
+
#
|
|
90
|
+
# @param flow_file [String]
|
|
91
|
+
# @param answers_file [String, nil]
|
|
92
|
+
# @param yes [Boolean]
|
|
93
|
+
# @return [Hash] Inquirex::Headless#call results
|
|
94
|
+
def run_headless(flow_file, answers_file, yes)
|
|
95
|
+
answers = answers_file ? AnswersFile.load(answers_file) : {}
|
|
96
|
+
headless = Inquirex::Headless.new(FlowLoader.load(flow_file), answers:, yes:)
|
|
97
|
+
headless.engine.after_completion { |eng| eng.completion_metadata = build_completion_metadata(network: false) }
|
|
98
|
+
headless.call
|
|
99
|
+
end
|
|
100
|
+
|
|
37
101
|
# @param flow_file [String]
|
|
38
102
|
# @return [Inquirex::Engine]
|
|
39
103
|
def run_flow(flow_file)
|
|
@@ -49,7 +113,9 @@ module Inquirex
|
|
|
49
113
|
|
|
50
114
|
until engine.finished?
|
|
51
115
|
step = engine.current_step
|
|
52
|
-
|
|
116
|
+
# history already ends with the current step, so its length is
|
|
117
|
+
# the step's number; adding one printed "Step 2" for the first.
|
|
118
|
+
next_step(engine.current_step_id, engine.history.length)
|
|
53
119
|
|
|
54
120
|
if step.respond_to?(:llm_verb?) && step.llm_verb?
|
|
55
121
|
run_llm_step(engine, step, adapter, renderer)
|
|
@@ -57,8 +123,7 @@ module Inquirex
|
|
|
57
123
|
renderer.render(step)
|
|
58
124
|
engine.advance
|
|
59
125
|
else
|
|
60
|
-
|
|
61
|
-
engine.answer(renderer.render(step, suggestion:))
|
|
126
|
+
renderer.collect(engine)
|
|
62
127
|
end
|
|
63
128
|
end
|
|
64
129
|
|
|
@@ -85,37 +150,33 @@ module Inquirex
|
|
|
85
150
|
raise
|
|
86
151
|
end
|
|
87
152
|
|
|
88
|
-
#
|
|
153
|
+
# The adapter inquirex-llm's configuration selects (first match wins):
|
|
89
154
|
# INQUIREX_LLM_ADAPTER=null → NullAdapter (forced offline)
|
|
90
155
|
# INQUIREX_LLM_ADAPTER=anthropic → AnthropicAdapter (fails if no key)
|
|
91
156
|
# INQUIREX_LLM_ADAPTER=openai → OpenAIAdapter (fails if no key)
|
|
157
|
+
# INQUIREX_LLM_ADAPTER=openrouter → OpenRouterAdapter (never auto-detected)
|
|
158
|
+
# INQUIREX_LLM_ADAPTER=jev → JevAdapter (never auto-detected)
|
|
92
159
|
# ANTHROPIC_API_KEY present → AnthropicAdapter
|
|
93
160
|
# OPENAI_API_KEY present → OpenAIAdapter
|
|
94
161
|
# otherwise → NullAdapter
|
|
162
|
+
#
|
|
163
|
+
# The configuration reads ENV once, and load_dotenv! has just added
|
|
164
|
+
# to it, so it is rebuilt first; a key that lives only in .env would
|
|
165
|
+
# otherwise be missed.
|
|
166
|
+
#
|
|
167
|
+
# @return [Inquirex::LLM::Adapter]
|
|
95
168
|
def build_llm_adapter
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return Inquirex::LLM::AnthropicAdapter.new if forced == "anthropic"
|
|
99
|
-
return Inquirex::LLM::OpenAIAdapter.new if forced == "openai"
|
|
100
|
-
|
|
101
|
-
if env_key?("ANTHROPIC_API_KEY") && defined?(Inquirex::LLM::AnthropicAdapter)
|
|
102
|
-
Inquirex::LLM::AnthropicAdapter.new
|
|
103
|
-
elsif env_key?("OPENAI_API_KEY") && defined?(Inquirex::LLM::OpenAIAdapter)
|
|
104
|
-
Inquirex::LLM::OpenAIAdapter.new
|
|
105
|
-
else
|
|
106
|
-
Inquirex::LLM::NullAdapter.new
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def env_key?(name)
|
|
111
|
-
v = ENV.fetch(name, nil)
|
|
112
|
-
!v.nil? && !v.empty?
|
|
169
|
+
Inquirex::LLM.reset_configuration!
|
|
170
|
+
Inquirex::LLM.adapter
|
|
113
171
|
end
|
|
114
172
|
|
|
173
|
+
# OpenRouterAdapter subclasses OpenAIAdapter, so it must be matched first.
|
|
115
174
|
def adapter_label(adapter)
|
|
116
175
|
case adapter
|
|
117
|
-
when Inquirex::LLM::AnthropicAdapter
|
|
118
|
-
when Inquirex::LLM::
|
|
176
|
+
when Inquirex::LLM::AnthropicAdapter then "Claude"
|
|
177
|
+
when Inquirex::LLM::OpenRouterAdapter then "OpenRouter"
|
|
178
|
+
when Inquirex::LLM::OpenAIAdapter then "GPT"
|
|
179
|
+
when Inquirex::LLM::JevAdapter then "Jev"
|
|
119
180
|
else "the null adapter"
|
|
120
181
|
end
|
|
121
182
|
rescue NameError
|
|
@@ -180,16 +241,10 @@ module Inquirex
|
|
|
180
241
|
end
|
|
181
242
|
|
|
182
243
|
# @param flow_file [String]
|
|
183
|
-
# @param
|
|
244
|
+
# @param run [Hash] :path_taken, :answers, :steps_completed
|
|
184
245
|
# @return [Hash]
|
|
185
|
-
def build_result(flow_file,
|
|
186
|
-
{
|
|
187
|
-
flow_file: flow_file,
|
|
188
|
-
path_taken: engine.history,
|
|
189
|
-
answers: engine.answers_with_metadata,
|
|
190
|
-
steps_completed: engine.history.length,
|
|
191
|
-
completed_at: Time.now.iso8601
|
|
192
|
-
}
|
|
246
|
+
def build_result(flow_file, run)
|
|
247
|
+
{ flow_file: flow_file, **run.slice(:path_taken, :answers, :steps_completed), completed_at: Time.now.iso8601 }
|
|
193
248
|
end
|
|
194
249
|
|
|
195
250
|
# Rich completion metadata for the answers. Only :engine and
|
|
@@ -197,13 +252,14 @@ module Inquirex
|
|
|
197
252
|
# best-effort: a failing one (offline host, no login name, blocked
|
|
198
253
|
# egress) yields nil and its member is dropped.
|
|
199
254
|
#
|
|
255
|
+
# @param network [Boolean] whether to ask a remote service for the public IP
|
|
200
256
|
# @return [Inquirex::CompletionMetadata]
|
|
201
|
-
def build_completion_metadata
|
|
257
|
+
def build_completion_metadata(network: true)
|
|
202
258
|
optional = {
|
|
203
259
|
uname: best_effort { OpenStruct.new(Etc.uname) },
|
|
204
260
|
user: best_effort { Etc.getlogin },
|
|
205
261
|
local_ip: best_effort { local_ip },
|
|
206
|
-
public_ip: best_effort { public_ip },
|
|
262
|
+
public_ip: network ? best_effort { public_ip } : nil,
|
|
207
263
|
terminal: ENV["LC_TERMINAL"] || ENV["TERM_PROGRAM"] || "Unknown"
|
|
208
264
|
}.compact
|
|
209
265
|
Inquirex::CompletionMetadata.new(
|
|
@@ -29,9 +29,8 @@ module Inquirex
|
|
|
29
29
|
# @raise [Inquirex::TTY::Error] on syntax error
|
|
30
30
|
def load
|
|
31
31
|
content = File.read(@path)
|
|
32
|
-
# rubocop:disable Security/Eval
|
|
32
|
+
# rubocop:disable-next Security/Eval
|
|
33
33
|
eval(content, TOPLEVEL_BINDING.dup, @path, 1)
|
|
34
|
-
# rubocop:enable Security/Eval
|
|
35
34
|
rescue SyntaxError => e
|
|
36
35
|
raise Inquirex::TTY::Error, "Syntax error in #{@path}: #{e.message}"
|
|
37
36
|
end
|