inquirex-tty 0.9.6 → 0.12.0
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 +5 -1
- data/.ruby-version +1 -0
- data/CHANGELOG.md +27 -12
- data/README.md +50 -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/back_key.rb +31 -0
- data/lib/inquirex/tty/commands/run.rb +103 -50
- data/lib/inquirex/tty/flow_loader.rb +1 -2
- data/lib/inquirex/tty/renderer.rb +146 -10
- data/lib/inquirex/tty/version.rb +1 -1
- data/lib/inquirex/tty.rb +2 -0
- metadata +213 -191
- /data/examples/{burning-man.rb → 12_burning_man.rb} +0 -0
- /data/examples/{makeabox.rb → 13_laser_cutting_tools.rb} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 18c484c5cbd16a7bfa1563c39d859fe48a3e2d783d5a06b186c1fae848703780
|
|
4
|
+
data.tar.gz: f9f2a1e74c8db8028db8b5d1581ff35b94dd376caf5fbd1acf96ce7b963c72ec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ffd4ae10ed877234d10bdba11d68f82d436d6422bac2d5471759ce14f0ed26ac9fe046aec0ed994693b7f989299ddf13f188c6e8122ec8263ff5fa49b7f79646
|
|
7
|
+
data.tar.gz: 5290b611af119b6222df018d542e3e41a03d169efdf10cb135f5d565e303e03e5fcdc270f5f778c32856b95f34eed9762bfdc2e40dbd9879e2d0839ab449ef48
|
|
@@ -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,29 @@
|
|
|
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
|
+
- **Ctrl-B goes back.** At any question after the first, Ctrl-B returns to the previous question with the earlier answer as its default (`Engine#back`, `#can_back?`, `#previous_answer` from inquirex 0.12.0). A tip line under the question names the key. 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
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- Step numbers in the progress line start at 1 again (the first step printed as "Step 2").
|
|
12
|
+
- **Requires `inquirex ~> 0.12` and `inquirex-llm ~> 0.12`.** 0.10.0 still required `~> 0.9.5` of both, so it could not be installed beside the 0.10.0 core.
|
|
13
|
+
- README samples use a fictional person (Alan Turing, `alan.turing@manchester.edu`), as does `examples/10_answers.json`.
|
|
14
|
+
- 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.
|
|
15
|
+
- CircleCI config copied from inquirex core; `.ruby-version` pins 4.1.0-preview1.
|
|
16
|
+
|
|
17
|
+
## [v0.10.0](https://github.com/inquirex/inquirex-tty/tree/v0.10.0) (2026-08-21)
|
|
18
|
+
|
|
19
|
+
[Full Changelog](https://github.com/inquirex/inquirex-tty/compare/v0.6.0...v0.10.0)
|
|
6
20
|
|
|
7
21
|
**Merged pull requests:**
|
|
8
22
|
|
|
9
|
-
-
|
|
23
|
+
- Set the lockstep version to 0.10.0 [#12](https://github.com/inquirex/inquirex-tty/pull/12) ([kigster](https://github.com/kigster))
|
|
24
|
+
- Bump to 0.9.6 [#11](https://github.com/inquirex/inquirex-tty/pull/11) ([kigster](https://github.com/kigster))
|
|
25
|
+
- 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))
|
|
26
|
+
- Fix CI: broken Gemfile path override, RuboCop scanning vendor/ [#9](https://github.com/inquirex/inquirex-tty/pull/9) ([kigster](https://github.com/kigster))
|
|
10
27
|
|
|
11
28
|
## [v0.6.0](https://github.com/inquirex/inquirex-tty/tree/v0.6.0) (2026-07-20)
|
|
12
29
|
|
|
@@ -14,8 +31,8 @@
|
|
|
14
31
|
|
|
15
32
|
**Merged pull requests:**
|
|
16
33
|
|
|
17
|
-
- Document lib in YARD style to 100% coverage [
|
|
18
|
-
- Use $stderr.puts for CLI messages, not warn [
|
|
34
|
+
- Document lib in YARD style to 100% coverage [#8](https://github.com/inquirex/inquirex-tty/pull/8) ([kigster](https://github.com/kigster))
|
|
35
|
+
- Use $stderr.puts for CLI messages, not warn [#7](https://github.com/inquirex/inquirex-tty/pull/7) ([kigster](https://github.com/kigster))
|
|
19
36
|
|
|
20
37
|
## [v0.5.0](https://github.com/inquirex/inquirex-tty/tree/v0.5.0) (2026-07-16)
|
|
21
38
|
|
|
@@ -27,12 +44,10 @@
|
|
|
27
44
|
|
|
28
45
|
**Merged pull requests:**
|
|
29
46
|
|
|
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
|
-
|
|
47
|
+
- Raise inquirex-tty test coverage from 79% to 99% [#5](https://github.com/inquirex/inquirex-tty/pull/5) ([kigster](https://github.com/kigster))
|
|
48
|
+
- Update bundled dependencies [#4](https://github.com/inquirex/inquirex-tty/pull/4) ([kigster](https://github.com/kigster))
|
|
49
|
+
- 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))
|
|
50
|
+
- Add inquirex export command and shared OutputPath helper [#2](https://github.com/inquirex/inquirex-tty/pull/2) ([kigster](https://github.com/kigster))
|
|
51
|
+
- Simplify graph command: merge open-graph, add "both" format [#1](https://github.com/inquirex/inquirex-tty/pull/1) ([kigster](https://github.com/kigster))
|
|
37
52
|
|
|
38
53
|
\* *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,49 @@ 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
|
+
**Going back.** Press **Ctrl-B** at any question to return to the previous one, with your earlier answer filled in as the default. The tip line under each question after the first names the key. Display steps and LLM steps in between are stepped over.
|
|
87
|
+
|
|
88
|
+
**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.
|
|
89
|
+
|
|
90
|
+
```yaml
|
|
91
|
+
# answers.yml
|
|
92
|
+
filing_status: single
|
|
93
|
+
income_types: [W2]
|
|
94
|
+
state_filing: [California]
|
|
95
|
+
foreign_accounts: "no"
|
|
96
|
+
deduction_types: [None]
|
|
97
|
+
client_name: Alan Turing
|
|
98
|
+
client_email: alan.turing@manchester.edu
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# dependents is left out: --yes answers it with its default, 0
|
|
103
|
+
inqt run examples/08_tax_preparer.rb --answers answers.yml --yes > results.json
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- Answers are passed to the engine as written: a multi-select takes a list of option values, not their labels.
|
|
107
|
+
- 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`.
|
|
108
|
+
- A required step with no answer stops the run: it exits 1 and names the step on stderr.
|
|
79
109
|
|
|
80
110
|
On completion, outputs a JSON summary:
|
|
81
111
|
|
|
@@ -94,8 +124,8 @@ On completion, outputs a JSON summary:
|
|
|
94
124
|
"state_filing": ["California"],
|
|
95
125
|
"foreign_accounts": "no",
|
|
96
126
|
"deduction_types": ["Medical"],
|
|
97
|
-
"client_name": "
|
|
98
|
-
"client_email": "
|
|
127
|
+
"client_name": "Alan Turing",
|
|
128
|
+
"client_email": "alan.turing@manchester.edu"
|
|
99
129
|
},
|
|
100
130
|
"steps_completed": 10,
|
|
101
131
|
"completed_at": "2026-04-13T23:51:33-07:00"
|
|
@@ -214,10 +244,10 @@ Step 8: deduction_types
|
|
|
214
244
|
Which additional deductions apply to you? Medical
|
|
215
245
|
Step 9: client_name
|
|
216
246
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
217
|
-
Your name, please:
|
|
247
|
+
Your name, please: Alan Turing
|
|
218
248
|
Step 10: client_email
|
|
219
249
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
220
|
-
Your email address:
|
|
250
|
+
Your email address: alan.turing@manchester.edu
|
|
221
251
|
Step 11: thanks
|
|
222
252
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
223
253
|
|
|
@@ -284,6 +314,8 @@ When a flow contains [inquirex-llm](../inquirex-llm) verbs (`clarify`, `describe
|
|
|
284
314
|
| `OPENAI_API_KEY` is set | `Inquirex::LLM::OpenAIAdapter` |
|
|
285
315
|
| nothing set | `Inquirex::LLM::NullAdapter` (demo) |
|
|
286
316
|
|
|
317
|
+
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.
|
|
318
|
+
|
|
287
319
|
### End-to-end example
|
|
288
320
|
|
|
289
321
|
`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 +454,9 @@ inquirex-tty/
|
|
|
422
454
|
│ ├── graph.rb # Mermaid diagram export
|
|
423
455
|
│ ├── export.rb # JSON / YAML serialization
|
|
424
456
|
│ └── version.rb # Print version info
|
|
425
|
-
├── renderer.rb # Node → tty-prompt widget dispatcher
|
|
457
|
+
├── renderer.rb # Node → tty-prompt widget dispatcher, Ctrl-B back
|
|
458
|
+
├── back_key.rb # The back key and the exception that unwinds a prompt
|
|
459
|
+
├── answers_file.rb # --answers: JSON or YAML preset answers
|
|
426
460
|
├── flow_loader.rb # Load .rb flow definitions
|
|
427
461
|
├── output_path.rb # Shared -o/--output path resolution
|
|
428
462
|
├── ui_helper.rb # TTY::Box/Pastel/Screen helpers
|
|
@@ -433,6 +467,8 @@ inquirex-tty/
|
|
|
433
467
|
|
|
434
468
|
`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
469
|
|
|
470
|
+
`Renderer#collect(engine)` asks the engine's current step and answers it, or, when the user presses Ctrl-B, calls `Engine#back` and returns `:back`; the run loop then asks the previous question again with `Engine#previous_answer` as its default. Ctrl-B raises `Inquirex::TTY::BackKey::Pressed` from a handler on tty-reader's `:keyctrl_b` event, which unwinds whichever tty-prompt widget is open.
|
|
471
|
+
|
|
436
472
|
The `TTY::Prompt` instance is injectable for testing:
|
|
437
473
|
|
|
438
474
|
```ruby
|
|
@@ -450,6 +486,8 @@ A mixin module providing styled output helpers (`box`, `info`, `success`, `error
|
|
|
450
486
|
|
|
451
487
|
## Development
|
|
452
488
|
|
|
489
|
+
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.
|
|
490
|
+
|
|
453
491
|
```bash
|
|
454
492
|
bin/setup # install dependencies
|
|
455
493
|
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
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module TTY
|
|
5
|
+
# The key that takes a TTY run back to the previous question.
|
|
6
|
+
#
|
|
7
|
+
# Ctrl-B, because it arrives: tty-reader maps byte 0x02 to +:ctrl_b+ in
|
|
8
|
+
# every terminal, and tty-prompt binds nothing to it. Cmd-Backspace, the
|
|
9
|
+
# first candidate, reaches a program only where the user has mapped it by
|
|
10
|
+
# hand (iTerm2 sends nothing by default), so a run could never rely on it.
|
|
11
|
+
module BackKey
|
|
12
|
+
# @return [Symbol] the tty-reader key name
|
|
13
|
+
NAME = :ctrl_b
|
|
14
|
+
|
|
15
|
+
# @return [Symbol] the event tty-reader publishes when the key is pressed
|
|
16
|
+
EVENT = :"key#{NAME}"
|
|
17
|
+
|
|
18
|
+
# @return [String] how the tip line names the key
|
|
19
|
+
LABEL = "Ctrl-B"
|
|
20
|
+
|
|
21
|
+
# Raised from inside a tty-prompt read loop when the back key is pressed,
|
|
22
|
+
# so whichever widget is open (ask, select, multi_select, yes?) unwinds
|
|
23
|
+
# without returning an answer. tty-prompt offers no hook that aborts a
|
|
24
|
+
# question, and a subclass per widget would mean overriding the read
|
|
25
|
+
# loop of each one; one exception covers them all.
|
|
26
|
+
class Pressed < StandardError
|
|
27
|
+
def initialize(msg = "#{LABEL} pressed") = super
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -3,37 +3,98 @@
|
|
|
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
|
-
if options[:output]
|
|
22
|
-
File.write(options[:output], json)
|
|
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])
|
|
27
32
|
rescue Inquirex::TTY::Error => e
|
|
28
|
-
error(e.message)
|
|
33
|
+
on_stderr { error(e.message) }
|
|
29
34
|
exit 1
|
|
30
35
|
rescue Inquirex::Errors::Error => e
|
|
31
|
-
error("Engine error: #{e.message}")
|
|
36
|
+
on_stderr { error("Engine error: #{e.message}") }
|
|
32
37
|
exit 1
|
|
33
38
|
end
|
|
34
39
|
|
|
35
40
|
private
|
|
36
41
|
|
|
42
|
+
# @param flow_file [String]
|
|
43
|
+
# @param answers [String, nil] answers file path
|
|
44
|
+
# @param yes [Boolean]
|
|
45
|
+
# @return [Hash]
|
|
46
|
+
def results_for(flow_file, answers: nil, yes: false, **)
|
|
47
|
+
if answers || yes
|
|
48
|
+
build_result(flow_file, run_headless(flow_file, answers, yes))
|
|
49
|
+
else
|
|
50
|
+
engine = run_flow(flow_file)
|
|
51
|
+
build_result(flow_file,
|
|
52
|
+
path_taken: engine.history,
|
|
53
|
+
answers: engine.answers_with_metadata,
|
|
54
|
+
steps_completed: engine.history.length)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Points $stdout at stderr for the block, so every `puts` and every
|
|
59
|
+
# TTY::Prompt built inside it (its output defaults to $stdout when it
|
|
60
|
+
# is created) write to stderr.
|
|
61
|
+
#
|
|
62
|
+
# @return [Object] the block's value
|
|
63
|
+
def on_stderr
|
|
64
|
+
original = $stdout
|
|
65
|
+
$stdout = $stderr
|
|
66
|
+
yield
|
|
67
|
+
ensure
|
|
68
|
+
$stdout = original
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param json [String]
|
|
72
|
+
# @param output [String, nil] a path, "-" or nil for stdout
|
|
73
|
+
# @return [void]
|
|
74
|
+
def write_results(json, output)
|
|
75
|
+
if output.nil? || output == "-"
|
|
76
|
+
$stdout.puts json
|
|
77
|
+
else
|
|
78
|
+
File.write(output, "#{json}\n")
|
|
79
|
+
warn "Results saved to #{output}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Runs the flow with no TTY. Completion metadata is gathered without
|
|
84
|
+
# the public-IP probe: a headless run is for tests and CI, and should
|
|
85
|
+
# neither wait on nor reach the network.
|
|
86
|
+
#
|
|
87
|
+
# @param flow_file [String]
|
|
88
|
+
# @param answers_file [String, nil]
|
|
89
|
+
# @param yes [Boolean]
|
|
90
|
+
# @return [Hash] Inquirex::Headless#call results
|
|
91
|
+
def run_headless(flow_file, answers_file, yes)
|
|
92
|
+
answers = answers_file ? AnswersFile.load(answers_file) : {}
|
|
93
|
+
headless = Inquirex::Headless.new(FlowLoader.load(flow_file), answers:, yes:)
|
|
94
|
+
headless.engine.after_completion { |eng| eng.completion_metadata = build_completion_metadata(network: false) }
|
|
95
|
+
headless.call
|
|
96
|
+
end
|
|
97
|
+
|
|
37
98
|
# @param flow_file [String]
|
|
38
99
|
# @return [Inquirex::Engine]
|
|
39
100
|
def run_flow(flow_file)
|
|
@@ -49,7 +110,9 @@ module Inquirex
|
|
|
49
110
|
|
|
50
111
|
until engine.finished?
|
|
51
112
|
step = engine.current_step
|
|
52
|
-
|
|
113
|
+
# history already ends with the current step, so its length is
|
|
114
|
+
# the step's number; adding one printed "Step 2" for the first.
|
|
115
|
+
next_step(engine.current_step_id, engine.history.length)
|
|
53
116
|
|
|
54
117
|
if step.respond_to?(:llm_verb?) && step.llm_verb?
|
|
55
118
|
run_llm_step(engine, step, adapter, renderer)
|
|
@@ -57,8 +120,7 @@ module Inquirex
|
|
|
57
120
|
renderer.render(step)
|
|
58
121
|
engine.advance
|
|
59
122
|
else
|
|
60
|
-
|
|
61
|
-
engine.answer(renderer.render(step, suggestion:))
|
|
123
|
+
renderer.collect(engine)
|
|
62
124
|
end
|
|
63
125
|
end
|
|
64
126
|
|
|
@@ -85,37 +147,33 @@ module Inquirex
|
|
|
85
147
|
raise
|
|
86
148
|
end
|
|
87
149
|
|
|
88
|
-
#
|
|
150
|
+
# The adapter inquirex-llm's configuration selects (first match wins):
|
|
89
151
|
# INQUIREX_LLM_ADAPTER=null → NullAdapter (forced offline)
|
|
90
152
|
# INQUIREX_LLM_ADAPTER=anthropic → AnthropicAdapter (fails if no key)
|
|
91
153
|
# INQUIREX_LLM_ADAPTER=openai → OpenAIAdapter (fails if no key)
|
|
154
|
+
# INQUIREX_LLM_ADAPTER=openrouter → OpenRouterAdapter (never auto-detected)
|
|
155
|
+
# INQUIREX_LLM_ADAPTER=jev → JevAdapter (never auto-detected)
|
|
92
156
|
# ANTHROPIC_API_KEY present → AnthropicAdapter
|
|
93
157
|
# OPENAI_API_KEY present → OpenAIAdapter
|
|
94
158
|
# otherwise → NullAdapter
|
|
159
|
+
#
|
|
160
|
+
# The configuration reads ENV once, and load_dotenv! has just added
|
|
161
|
+
# to it, so it is rebuilt first; a key that lives only in .env would
|
|
162
|
+
# otherwise be missed.
|
|
163
|
+
#
|
|
164
|
+
# @return [Inquirex::LLM::Adapter]
|
|
95
165
|
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?
|
|
166
|
+
Inquirex::LLM.reset_configuration!
|
|
167
|
+
Inquirex::LLM.adapter
|
|
113
168
|
end
|
|
114
169
|
|
|
170
|
+
# OpenRouterAdapter subclasses OpenAIAdapter, so it must be matched first.
|
|
115
171
|
def adapter_label(adapter)
|
|
116
172
|
case adapter
|
|
117
|
-
when Inquirex::LLM::AnthropicAdapter
|
|
118
|
-
when Inquirex::LLM::
|
|
173
|
+
when Inquirex::LLM::AnthropicAdapter then "Claude"
|
|
174
|
+
when Inquirex::LLM::OpenRouterAdapter then "OpenRouter"
|
|
175
|
+
when Inquirex::LLM::OpenAIAdapter then "GPT"
|
|
176
|
+
when Inquirex::LLM::JevAdapter then "Jev"
|
|
119
177
|
else "the null adapter"
|
|
120
178
|
end
|
|
121
179
|
rescue NameError
|
|
@@ -180,16 +238,10 @@ module Inquirex
|
|
|
180
238
|
end
|
|
181
239
|
|
|
182
240
|
# @param flow_file [String]
|
|
183
|
-
# @param
|
|
241
|
+
# @param run [Hash] :path_taken, :answers, :steps_completed
|
|
184
242
|
# @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
|
-
}
|
|
243
|
+
def build_result(flow_file, run)
|
|
244
|
+
{ flow_file: flow_file, **run.slice(:path_taken, :answers, :steps_completed), completed_at: Time.now.iso8601 }
|
|
193
245
|
end
|
|
194
246
|
|
|
195
247
|
# Rich completion metadata for the answers. Only :engine and
|
|
@@ -197,13 +249,14 @@ module Inquirex
|
|
|
197
249
|
# best-effort: a failing one (offline host, no login name, blocked
|
|
198
250
|
# egress) yields nil and its member is dropped.
|
|
199
251
|
#
|
|
252
|
+
# @param network [Boolean] whether to ask a remote service for the public IP
|
|
200
253
|
# @return [Inquirex::CompletionMetadata]
|
|
201
|
-
def build_completion_metadata
|
|
254
|
+
def build_completion_metadata(network: true)
|
|
202
255
|
optional = {
|
|
203
256
|
uname: best_effort { OpenStruct.new(Etc.uname) },
|
|
204
257
|
user: best_effort { Etc.getlogin },
|
|
205
258
|
local_ip: best_effort { local_ip },
|
|
206
|
-
public_ip: best_effort { public_ip },
|
|
259
|
+
public_ip: network ? best_effort { public_ip } : nil,
|
|
207
260
|
terminal: ENV["LC_TERMINAL"] || ENV["TERM_PROGRAM"] || "Unknown"
|
|
208
261
|
}.compact
|
|
209
262
|
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
|