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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e709789b8bf0165eb47a13a0a0c60b269f48c965def43cb612f6d228a7f7c646
4
- data.tar.gz: 2daf251feeea0f6d740b09a81a66f26f947512990b13787a0e92bd9b238a73f5
3
+ metadata.gz: ef0bce06b980c6e167a092cff784d1f63f2eea80d72b78384841455baac1865c
4
+ data.tar.gz: 87306ae23bf1cb5472d8913970b08c26e420cc1ee5ae3659974195aa0639c97c
5
5
  SHA512:
6
- metadata.gz: 85177d25906c5f3ae7ede31b3278700999583973257a43d346c2286891691176ba51c6029599052d2e47ed551617c5769dbf58a71a89fae13b621e925c2a85d0
7
- data.tar.gz: 906b635c6375af98c382b06e65e05d60e26573b0042452823244d036d61953c61cbe166ddf8613e94b6e4aac11c6743818c21f92c02df8f1900ceac3580cba0e
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
@@ -3,3 +3,5 @@
3
3
  PATH_add exe
4
4
 
5
5
  [[ -f .env ]] && eval "$(cat .env | sed 's/^/export /g')"
6
+
7
+ [[ -f ../.envrc ]] && source ../.envrc
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
- ## [Unreleased](https://github.com/inquirex/inquirex-tty/tree/HEAD)
3
+ ## 0.12.0 (unreleased)
4
4
 
5
- [Full Changelog](https://github.com/inquirex/inquirex-tty/compare/v0.6.0...HEAD)
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
- - Fix CI: broken Gemfile path override, RuboCop scanning vendor/ [\#9](https://github.com/inquirex/inquirex-tty/pull/9) ([kigster](https://github.com/kigster))
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 [\#8](https://github.com/inquirex/inquirex-tty/pull/8) ([kigster](https://github.com/kigster))
18
- - Use $stderr.puts for CLI messages, not warn [\#7](https://github.com/inquirex/inquirex-tty/pull/7) ([kigster](https://github.com/kigster))
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% [\#5](https://github.com/inquirex/inquirex-tty/pull/5) ([kigster](https://github.com/kigster))
31
- - Update bundled dependencies [\#4](https://github.com/inquirex/inquirex-tty/pull/4) ([kigster](https://github.com/kigster))
32
- - 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))
33
- - Add inquirex export command and shared OutputPath helper [\#2](https://github.com/inquirex/inquirex-tty/pull/2) ([kigster](https://github.com/kigster))
34
- - Simplify graph command: merge open-graph, add "both" format [\#1](https://github.com/inquirex/inquirex-tty/pull/1) ([kigster](https://github.com/kigster))
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 and then dump answers as json to stdout
66
+ # run it; the answers are printed as JSON on stdout
67
67
  inqt run examples/08_tax_preparer.rb
68
68
 
69
- # run it and save the anawers to a json file
70
- inqt run examples/08_tax_preparer.rb \
71
- --output answers.json
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 a file instead of stderr |
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": "Konstantin Gredeskoul",
98
- "client_email": "kigster@gmail.com"
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: Konstantin Gredeskoul
259
+ Your name, please: Alan Turing
218
260
  Step 10: client_email
219
261
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
220
- Your email address: kigster@gmail.com
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">96%</text>
19
- <text x="80" y="14">96%</text>
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>
@@ -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, if_rule: contains(:investment_details, "Crypto")
102
- transition to: :rental_details, if_rule: contains(:income_types, "Rental")
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
 
@@ -46,7 +46,7 @@
46
46
  "hsa_fsa"
47
47
  ],
48
48
  "timeline": "asap",
49
- "client_email": "kigster@gmail.com"
49
+ "client_email": "alan.turing@manchester.edu"
50
50
  },
51
51
  "steps_completed": 17,
52
52
  "completed_at": "2026-04-14T16:08:16-07:00"
@@ -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 interactively via TTY prompts and writes the
7
- # collected answers (plus metadata) as JSON to stderr or a file.
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 :output, aliases: ["-o"], desc: "Write JSON results to this file instead of stderr"
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
- engine = run_flow(flow_file)
19
- json = JSON.pretty_generate(build_result(flow_file, engine))
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])
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
- next_step(engine.current_step_id, engine.history.length + 1)
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
- suggestion = engine.suggestion_for(engine.current_step_id)
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
- # Adapter preference order (first match wins):
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
- forced = ENV["INQUIREX_LLM_ADAPTER"].to_s.downcase
97
- return Inquirex::LLM::NullAdapter.new if forced == "null"
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 then "Claude"
118
- when Inquirex::LLM::OpenAIAdapter then "GPT"
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 engine [Inquirex::Engine]
244
+ # @param run [Hash] :path_taken, :answers, :steps_completed
184
245
  # @return [Hash]
185
- def build_result(flow_file, engine)
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