inquirex-tty 0.10.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e709789b8bf0165eb47a13a0a0c60b269f48c965def43cb612f6d228a7f7c646
4
- data.tar.gz: 2daf251feeea0f6d740b09a81a66f26f947512990b13787a0e92bd9b238a73f5
3
+ metadata.gz: 18c484c5cbd16a7bfa1563c39d859fe48a3e2d783d5a06b186c1fae848703780
4
+ data.tar.gz: f9f2a1e74c8db8028db8b5d1581ff35b94dd376caf5fbd1acf96ce7b963c72ec
5
5
  SHA512:
6
- metadata.gz: 85177d25906c5f3ae7ede31b3278700999583973257a43d346c2286891691176ba51c6029599052d2e47ed551617c5769dbf58a71a89fae13b621e925c2a85d0
7
- data.tar.gz: 906b635c6375af98c382b06e65e05d60e26573b0042452823244d036d61953c61cbe166ddf8613e94b6e4aac11c6743818c21f92c02df8f1900ceac3580cba0e
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
@@ -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,29 @@
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
+ - **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
- - Fix CI: broken Gemfile path override, RuboCop scanning vendor/ [\#9](https://github.com/inquirex/inquirex-tty/pull/9) ([kigster](https://github.com/kigster))
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 [\#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))
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% [\#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
-
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 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
+ **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": "Konstantin Gredeskoul",
98
- "client_email": "kigster@gmail.com"
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: Konstantin Gredeskoul
247
+ Your name, please: Alan Turing
218
248
  Step 10: client_email
219
249
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
220
- Your email address: kigster@gmail.com
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">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
@@ -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 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])
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
- next_step(engine.current_step_id, engine.history.length + 1)
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
- suggestion = engine.suggestion_for(engine.current_step_id)
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
- # Adapter preference order (first match wins):
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
- 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?
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 then "Claude"
118
- when Inquirex::LLM::OpenAIAdapter then "GPT"
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 engine [Inquirex::Engine]
241
+ # @param run [Hash] :path_taken, :answers, :steps_completed
184
242
  # @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
- }
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