inquirex-tty 0.5.0 → 0.9.6
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/.github_changelog_generator +5 -0
- data/CHANGELOG.md +36 -3
- data/README.md +91 -106
- data/Rakefile +5 -2
- data/docs/badges/coverage_badge.svg +2 -2
- data/examples/10_real_tax_preparer.rb +40 -2
- data/examples/burning-man.rb +52 -0
- data/examples/makeabox.rb +93 -0
- data/exe/inqt +1 -0
- data/justfile +63 -11
- data/lib/inquirex/tty/commands/export.rb +4 -2
- data/lib/inquirex/tty/commands/graph.rb +6 -4
- data/lib/inquirex/tty/commands/run.rb +51 -3
- data/lib/inquirex/tty/commands/validate.rb +4 -4
- data/lib/inquirex/tty/commands/version.rb +3 -1
- data/lib/inquirex/tty/flow_loader.rb +6 -0
- data/lib/inquirex/tty/output_path.rb +5 -0
- data/lib/inquirex/tty/renderer.rb +74 -5
- data/lib/inquirex/tty/ui_helper.rb +22 -0
- data/lib/inquirex/tty/version.rb +1 -1
- data/lib/inquirex/tty.rb +8 -0
- data/sig/inquirex/tty.rbs +1 -0
- metadata +27 -8
- /data/exe/{inquirex → inquirex-tty} +0 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Inquirex.define do
|
|
2
|
+
start :do_you_have_a_minute
|
|
3
|
+
|
|
4
|
+
confirm :do_you_have_a_minute do
|
|
5
|
+
type :boolean
|
|
6
|
+
question "Do you you have a few minutes to help us understand how you use the site, what's working and what needs fixing?"
|
|
7
|
+
widget type: :toggle
|
|
8
|
+
widget target: :mobile, type: :yes_no_buttons
|
|
9
|
+
transition to: :name, if_rule: equals(:do_you_have_a_minute, true)
|
|
10
|
+
transition to: :no_problem
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
ask :name do
|
|
14
|
+
type :string
|
|
15
|
+
question "What is your name? However you'd like to be called."
|
|
16
|
+
widget type: :text_input
|
|
17
|
+
widget target: :mobile, type: :text_input
|
|
18
|
+
transition to: :email
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
ask :email do
|
|
22
|
+
type :email
|
|
23
|
+
question "Please share with us your email address so that we can notify you when your ideas and feedback has been put to good use."
|
|
24
|
+
widget type: :email_input
|
|
25
|
+
widget target: :mobile, type: :email_input
|
|
26
|
+
transition to: :other_tools
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
say :no_problem do
|
|
30
|
+
text "Not a problem. If you ever change your mind, just hit that round circle and answer the quesitons,. Adios!"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
ask :other_tools do
|
|
34
|
+
type :boolean
|
|
35
|
+
question "Do you use any other online tools for generating laser cuts or engraving?"
|
|
36
|
+
default false
|
|
37
|
+
transition to: :uses_other_tools, if_rule: equals(:other_tools, true)
|
|
38
|
+
transition to: :how_long_you_used_makeabox
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
ask :uses_other_tools do
|
|
42
|
+
type :multi_enum
|
|
43
|
+
question "May we ask which tools do you use to building enclosures cut with a laser cutter? If the tool you use is not listed, choose Unlisted."
|
|
44
|
+
options i_dont: "I don't use Any of Them",
|
|
45
|
+
makeabox: "MakeABox.io (this site)",
|
|
46
|
+
makercase: "Makercase",
|
|
47
|
+
boxes_py: "Boxes.py",
|
|
48
|
+
www_atomm_com: "Atomm.com",
|
|
49
|
+
vector_painter: "Vector Painter.com",
|
|
50
|
+
laserbiz_ru: "Laserbiz.ru",
|
|
51
|
+
hessmer_org_gears: "hessmer.org/gears",
|
|
52
|
+
unlisted: "Unlisted (Other)"
|
|
53
|
+
transition to: :makeabox_only, if_rule: contains(:uses_other_tools, "makeabox")
|
|
54
|
+
transition to: :other, if_rule: contains(:uses_other_tools, "unlisted")
|
|
55
|
+
transition to: :feedback
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
say :makeabox_only do
|
|
59
|
+
text "Thanks for using MakeABox only :) We hope to improve this application futher and we appreciate your choice, considering there are a few out there :)"
|
|
60
|
+
transition to: :how_long_you_used_makeabox
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
ask :how_long_you_used_makeabox do
|
|
64
|
+
type :enum
|
|
65
|
+
question "Give us a sense about how long have you been used MakeABox.io..."
|
|
66
|
+
options first_time: "My First TIme",
|
|
67
|
+
about_a_month: "About a Month",
|
|
68
|
+
a_few_months: "A few Months",
|
|
69
|
+
at_least_a_year: "At least a Year",
|
|
70
|
+
over_a_year: "Over a Year",
|
|
71
|
+
over_two_years: "Over two Years"
|
|
72
|
+
default :first_time
|
|
73
|
+
transition to: :other, if_rule: contains(:uses_other_tools, "unlisted")
|
|
74
|
+
transition to: :feedback
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
ask :other do
|
|
78
|
+
type :text
|
|
79
|
+
widget type: :text_input
|
|
80
|
+
question "Can you list the tools not listed in the previous question?"
|
|
81
|
+
transition to: :feedback
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
ask :feedback do
|
|
85
|
+
question "Pelase provide any feedback or bug reports that will make MakeABox.io better."
|
|
86
|
+
type :text
|
|
87
|
+
transition to: :thanks
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
say :thanks do
|
|
91
|
+
text "We appreciate you taking the time to answer some of the questions! See you around!"
|
|
92
|
+
end
|
|
93
|
+
end
|
data/exe/inqt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
inquirex-tty
|
data/justfile
CHANGED
|
@@ -4,7 +4,26 @@ set shell := ["bash", "-c"]
|
|
|
4
4
|
|
|
5
5
|
repo := 'git@github.com:inquirex/inquirex-tty.git'
|
|
6
6
|
version := `grep VERSION lib/inquirex/tty/version.rb | awk '{print $3}' | tr -d '"' | tr -d '\n'`
|
|
7
|
-
|
|
7
|
+
# The `-` matters: `rbenv init bash` prints human instructions ("skipping
|
|
8
|
+
# ~/.bash_login: already configured"), which eval then tries to run and the
|
|
9
|
+
# recipe dies with "skipping: command not found". `rbenv init - bash` prints
|
|
10
|
+
# the shell code that is meant to be eval'd.
|
|
11
|
+
rbenv := 'eval "$(rbenv init - bash 2>/dev/null || true)"; bundle exec '
|
|
12
|
+
|
|
13
|
+
# 1Password secret reference for the RubyGems TOTP, and the full path to `op`
|
|
14
|
+
# because a recipe does not inherit an interactive shell's PATH.
|
|
15
|
+
# The account is NOT named here: it is a private 1Password address and this
|
|
16
|
+
# file is public. `op` reads it from OP_ACCOUNT, exported by the ecosystem
|
|
17
|
+
# root's .envrc, which lives outside every repo. Two accounts are
|
|
18
|
+
# registered and only one holds the open-source-repos vault, so without
|
|
19
|
+
# OP_ACCOUNT the read can resolve against the wrong one, return nothing,
|
|
20
|
+
# and publish silently falls back to prompting mid-release.
|
|
21
|
+
op := '/opt/homebrew/bin/op'
|
|
22
|
+
otp_ref := 'op://open-source-repos/ruby-gems/one-time password?attribute=otp'
|
|
23
|
+
|
|
24
|
+
gem_name := 'inquirex-tty'
|
|
25
|
+
gem_file := 'pkg/' + gem_name + '-' + version + '.gem'
|
|
26
|
+
gem_url := 'https://rubygems.org/gems/' + gem_name
|
|
8
27
|
|
|
9
28
|
[no-exit-message]
|
|
10
29
|
recipes:
|
|
@@ -29,15 +48,15 @@ format:
|
|
|
29
48
|
|
|
30
49
|
# Run a flow interactively
|
|
31
50
|
run flow_file:
|
|
32
|
-
{{ rbenv }} exe/
|
|
51
|
+
{{ rbenv }} exe/inqt run {{flow_file}}
|
|
33
52
|
|
|
34
53
|
# Validate a flow definition
|
|
35
54
|
validate flow_file:
|
|
36
|
-
{{ rbenv }} exe/
|
|
55
|
+
{{ rbenv }} exe/inqt validate {{flow_file}}
|
|
37
56
|
|
|
38
57
|
# Export a flow as a Mermaid diagram (stdout)
|
|
39
58
|
graph flow_file:
|
|
40
|
-
{{ rbenv }} exe/
|
|
59
|
+
{{ rbenv }} exe/inqt graph {{flow_file}}
|
|
41
60
|
|
|
42
61
|
# Validate all examples
|
|
43
62
|
examples:
|
|
@@ -45,7 +64,7 @@ examples:
|
|
|
45
64
|
set -e
|
|
46
65
|
for f in examples/*.rb; do
|
|
47
66
|
echo "=== $f ==="
|
|
48
|
-
{{ rbenv }} exe/
|
|
67
|
+
{{ rbenv }} exe/inqt validate "$f"
|
|
49
68
|
echo ""
|
|
50
69
|
done
|
|
51
70
|
|
|
@@ -59,12 +78,45 @@ clean:
|
|
|
59
78
|
/usr/bin/find . -name .DS_Store -delete -print || true
|
|
60
79
|
rm -rf tmp/*
|
|
61
80
|
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
# `gem push` rather than `rake release`: release also guards the tree, tags and
|
|
82
|
+
# pushes git — which `just release` does deliberately and separately — and it
|
|
83
|
+
# gives no way to pass a 2FA code, so it always stopped to prompt.
|
|
84
|
+
#
|
|
85
|
+
# The code comes from 1Password unless one is passed in:
|
|
86
|
+
#
|
|
87
|
+
# just publish # read the code from 1Password
|
|
88
|
+
# just publish 123456 # use this code
|
|
89
|
+
#
|
|
90
|
+
# `just publish-all` in inquirex-tools passes one, because a TOTP is single-use:
|
|
91
|
+
# four gems reading the same 30-second window would have the second push
|
|
92
|
+
# rejected as a replay.
|
|
93
|
+
#
|
|
94
|
+
# Build the .gem and push it to RubyGems, non-interactively
|
|
95
|
+
publish otp="": build
|
|
96
|
+
#!/usr/bin/env bash
|
|
97
|
+
set -euo pipefail
|
|
98
|
+
eval "$(rbenv init - bash 2>/dev/null || true)"
|
|
99
|
+
|
|
100
|
+
mkdir -p pkg
|
|
101
|
+
gem build {{ gem_name }}.gemspec --output "{{ gem_file }}"
|
|
102
|
+
|
|
103
|
+
# `|| true` is load-bearing: under `set -e` a failed `op read` — not signed
|
|
104
|
+
# in to 1Password, item renamed, op not installed — would abort the recipe
|
|
105
|
+
# before the prompting fallback below could run.
|
|
106
|
+
otp="{{ otp }}"
|
|
107
|
+
[[ -n "${otp}" ]] || otp=$({{ op }} read "{{ otp_ref }}" 2>/dev/null || true)
|
|
108
|
+
|
|
109
|
+
if [[ -n "${otp}" ]]; then
|
|
110
|
+
gem push "{{ gem_file }}" --otp "${otp}"
|
|
111
|
+
else
|
|
112
|
+
echo "rubygems: no OTP available — gem push will prompt if 2FA is required."
|
|
113
|
+
gem push "{{ gem_file }}"
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
# Only reachable when the push succeeded: `set -e` aborts the recipe on a
|
|
117
|
+
# non-zero `gem push`, so the page never opens for a release that failed.
|
|
118
|
+
echo "published {{ gem_name }} {{ version }} → {{ gem_url }}"
|
|
119
|
+
open "{{ gem_url }}" 2>/dev/null || xdg-open "{{ gem_url }}" 2>/dev/null || true
|
|
68
120
|
|
|
69
121
|
# Tag v{{ version }}, publish the GH release, & refresh the Homebrew tap.
|
|
70
122
|
release:
|
|
@@ -12,9 +12,11 @@ module Inquirex
|
|
|
12
12
|
# inquirex export examples/08_tax_preparer.rb -f yml -o . # write 08_tax_preparer.yml to cwd
|
|
13
13
|
# inquirex export examples/08_tax_preparer.rb -o out.json # write to out.json
|
|
14
14
|
class Export < Dry::CLI::Command
|
|
15
|
+
# Multi-line help text shown by `inquirex export --help`.
|
|
15
16
|
LONG_DESCRIPTION = "Export a flow definition as JSON or YAML.\n\n" \
|
|
16
17
|
"Example:\n inquirex export examples/08_tax_preparer.rb -f yml -o ."
|
|
17
18
|
|
|
19
|
+
# One-line summary shown in the top-level command listing.
|
|
18
20
|
SHORT_DESCRIPTION = "Export a flow definition as JSON or YAML ."
|
|
19
21
|
|
|
20
22
|
if ARGV[0] == "export"
|
|
@@ -44,7 +46,7 @@ module Inquirex
|
|
|
44
46
|
content = serialize(definition, format)
|
|
45
47
|
write(content, flow_file, options[:output], extension_for(format))
|
|
46
48
|
rescue Inquirex::TTY::Error => e
|
|
47
|
-
|
|
49
|
+
$stderr.puts "Error: #{e.message}"
|
|
48
50
|
exit 1
|
|
49
51
|
end
|
|
50
52
|
|
|
@@ -73,7 +75,7 @@ module Inquirex
|
|
|
73
75
|
end
|
|
74
76
|
|
|
75
77
|
File.write(path, content)
|
|
76
|
-
|
|
78
|
+
$stderr.puts "Exported to #{path}"
|
|
77
79
|
end
|
|
78
80
|
end
|
|
79
81
|
end
|
|
@@ -5,11 +5,13 @@ module Inquirex
|
|
|
5
5
|
module Commands
|
|
6
6
|
# Exports a flow definition as a Mermaid flowchart (stdout or file).
|
|
7
7
|
class Graph < Dry::CLI::Command
|
|
8
|
+
# Multi-line help text shown by `inquirex graph --help`.
|
|
8
9
|
LONG_DESCRIPTION = "Export a flow definition as a Mermaid diagram source, an image, or both.\n " \
|
|
9
10
|
"Image generation requires mermaid-cli (npm install -g @mermaid-js/mermaid-cli)\n " \
|
|
10
11
|
"which this gem will attempt to install for you if mmdc command is not available.\n\n" \
|
|
11
12
|
"Example:\n inquirex graph qualify_dsl.rb --format both --output ~/Desktop --open"
|
|
12
13
|
|
|
14
|
+
# One-line summary shown in the top-level command listing.
|
|
13
15
|
SHORT_DESCRIPTION = "Export a flow definition as a Mermaid diagram source, an image, or both."
|
|
14
16
|
|
|
15
17
|
if ARGV[0] == "graph"
|
|
@@ -54,7 +56,7 @@ module Inquirex
|
|
|
54
56
|
write_image(source, OutputPath.resolve_with_default(flow_file, output, ".png"), options[:open])
|
|
55
57
|
end
|
|
56
58
|
rescue Inquirex::TTY::Error => e
|
|
57
|
-
|
|
59
|
+
$stderr.puts "Error: #{e.message}"
|
|
58
60
|
exit 1
|
|
59
61
|
end
|
|
60
62
|
|
|
@@ -66,7 +68,7 @@ module Inquirex
|
|
|
66
68
|
return
|
|
67
69
|
end
|
|
68
70
|
File.write(output_path, source)
|
|
69
|
-
|
|
71
|
+
$stderr.puts "Diagram written to #{output_path}"
|
|
70
72
|
end
|
|
71
73
|
|
|
72
74
|
def write_image(source, output_path, open_file)
|
|
@@ -81,14 +83,14 @@ module Inquirex
|
|
|
81
83
|
)
|
|
82
84
|
end
|
|
83
85
|
|
|
84
|
-
|
|
86
|
+
$stderr.puts "Diagram written to #{output_path}"
|
|
85
87
|
open_image_file(output_path) if open_file
|
|
86
88
|
end
|
|
87
89
|
|
|
88
90
|
def ensure_mermaid_cli_installed!
|
|
89
91
|
return if command_available?("mmdc")
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
$stderr.puts "Installing @mermaid-js/mermaid-cli..."
|
|
92
94
|
installed = system("npm install -g @mermaid-js/mermaid-cli")
|
|
93
95
|
return if installed && command_available?("mmdc")
|
|
94
96
|
|
|
@@ -22,7 +22,7 @@ module Inquirex
|
|
|
22
22
|
File.write(options[:output], json)
|
|
23
23
|
puts "\nResults saved to #{options[:output]}"
|
|
24
24
|
else
|
|
25
|
-
$stderr.puts json
|
|
25
|
+
$stderr.puts json
|
|
26
26
|
end
|
|
27
27
|
rescue Inquirex::TTY::Error => e
|
|
28
28
|
error(e.message)
|
|
@@ -43,6 +43,8 @@ module Inquirex
|
|
|
43
43
|
renderer = Renderer.new
|
|
44
44
|
adapter = build_llm_adapter
|
|
45
45
|
|
|
46
|
+
engine.after_completion { |eng| eng.completion_metadata = build_completion_metadata }
|
|
47
|
+
|
|
46
48
|
show_banner(definition)
|
|
47
49
|
|
|
48
50
|
until engine.finished?
|
|
@@ -55,7 +57,8 @@ module Inquirex
|
|
|
55
57
|
renderer.render(step)
|
|
56
58
|
engine.advance
|
|
57
59
|
else
|
|
58
|
-
engine.
|
|
60
|
+
suggestion = engine.suggestion_for(engine.current_step_id)
|
|
61
|
+
engine.answer(renderer.render(step, suggestion:))
|
|
59
62
|
end
|
|
60
63
|
end
|
|
61
64
|
|
|
@@ -183,11 +186,56 @@ module Inquirex
|
|
|
183
186
|
{
|
|
184
187
|
flow_file: flow_file,
|
|
185
188
|
path_taken: engine.history,
|
|
186
|
-
answers: engine.
|
|
189
|
+
answers: engine.answers_with_metadata,
|
|
187
190
|
steps_completed: engine.history.length,
|
|
188
191
|
completed_at: Time.now.iso8601
|
|
189
192
|
}
|
|
190
193
|
end
|
|
194
|
+
|
|
195
|
+
# Rich completion metadata for the answers. Only :engine and
|
|
196
|
+
# :engine_version are guaranteed — the environment probes are
|
|
197
|
+
# best-effort: a failing one (offline host, no login name, blocked
|
|
198
|
+
# egress) yields nil and its member is dropped.
|
|
199
|
+
#
|
|
200
|
+
# @return [Inquirex::CompletionMetadata]
|
|
201
|
+
def build_completion_metadata
|
|
202
|
+
optional = {
|
|
203
|
+
uname: best_effort { OpenStruct.new(Etc.uname) },
|
|
204
|
+
user: best_effort { Etc.getlogin },
|
|
205
|
+
local_ip: best_effort { local_ip },
|
|
206
|
+
public_ip: best_effort { public_ip },
|
|
207
|
+
terminal: ENV["LC_TERMINAL"] || ENV["TERM_PROGRAM"] || "Unknown"
|
|
208
|
+
}.compact
|
|
209
|
+
Inquirex::CompletionMetadata.new(
|
|
210
|
+
engine: "inquirex-tty",
|
|
211
|
+
engine_version: Inquirex::TTY::VERSION,
|
|
212
|
+
**optional
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def best_effort
|
|
217
|
+
yield
|
|
218
|
+
rescue StandardError
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The local address the OS would route out through — a UDP "connect"
|
|
223
|
+
# picks the outbound interface without sending a single packet.
|
|
224
|
+
def local_ip
|
|
225
|
+
UDPSocket.open do |socket|
|
|
226
|
+
socket.connect("8.8.8.8", 1)
|
|
227
|
+
socket.addr.last
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Short timeouts so a slow or airgapped network can never stall the
|
|
232
|
+
# CLI at completion time.
|
|
233
|
+
def public_ip
|
|
234
|
+
Net::HTTP.start("api.ipify.org", 443, use_ssl: true, open_timeout: 2, read_timeout: 2) do |http|
|
|
235
|
+
response = http.get("/")
|
|
236
|
+
response.is_a?(Net::HTTPSuccess) ? response.body.to_s.strip : nil
|
|
237
|
+
end
|
|
238
|
+
end
|
|
191
239
|
end
|
|
192
240
|
end
|
|
193
241
|
end
|
|
@@ -23,10 +23,10 @@ module Inquirex
|
|
|
23
23
|
exit 1
|
|
24
24
|
end
|
|
25
25
|
rescue Inquirex::TTY::Error => e
|
|
26
|
-
|
|
26
|
+
$stderr.puts "Error: #{e.message}"
|
|
27
27
|
exit 1
|
|
28
28
|
rescue Inquirex::Errors::Error => e
|
|
29
|
-
|
|
29
|
+
$stderr.puts "Definition error: #{e.message}"
|
|
30
30
|
exit 1
|
|
31
31
|
end
|
|
32
32
|
|
|
@@ -48,8 +48,8 @@ module Inquirex
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def print_errors(errors)
|
|
51
|
-
|
|
52
|
-
errors.each { |e|
|
|
51
|
+
$stderr.puts "Flow definition has #{errors.length} error(s):"
|
|
52
|
+
errors.each { |e| $stderr.puts " - #{e}" }
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def validate_definition(definition)
|
|
@@ -7,7 +7,9 @@ module Inquirex
|
|
|
7
7
|
class Version < Dry::CLI::Command
|
|
8
8
|
desc "Print version information"
|
|
9
9
|
|
|
10
|
-
#
|
|
10
|
+
# Prints the version of this gem and of the core inquirex gem.
|
|
11
|
+
# Any CLI options are accepted and ignored.
|
|
12
|
+
#
|
|
11
13
|
# @return [void]
|
|
12
14
|
def call(**)
|
|
13
15
|
puts "inquirex-tty #{Inquirex::TTY::VERSION}"
|
|
@@ -6,6 +6,12 @@ module Inquirex
|
|
|
6
6
|
# binding. The file is expected to call +Inquirex.define+ and return an
|
|
7
7
|
# +Inquirex::Definition+.
|
|
8
8
|
class FlowLoader
|
|
9
|
+
# Loads and evaluates a flow definition file in one call.
|
|
10
|
+
#
|
|
11
|
+
# @example Load a flow definition from disk
|
|
12
|
+
# definition = Inquirex::TTY::FlowLoader.load("examples/08_tax_preparer.rb")
|
|
13
|
+
# definition.step_ids # => [:filing_status, :dependents, ...]
|
|
14
|
+
#
|
|
9
15
|
# @param path [String] path to a .rb flow definition file
|
|
10
16
|
# @return [Inquirex::Definition]
|
|
11
17
|
# @raise [Inquirex::TTY::Error] if file is missing, not .rb, or has syntax errors
|
|
@@ -18,6 +18,11 @@ module Inquirex
|
|
|
18
18
|
|
|
19
19
|
# Resolves an output path, returning nil when the caller should use stdout.
|
|
20
20
|
#
|
|
21
|
+
# @example Resolve the --output option for a JSON export
|
|
22
|
+
# OutputPath.resolve("flows/intake.rb", nil, ".json") # => nil (stdout)
|
|
23
|
+
# OutputPath.resolve("flows/intake.rb", "out", ".json") # => "out.json"
|
|
24
|
+
# OutputPath.resolve("flows/intake.rb", "/tmp", ".json") # => "/tmp/intake.json"
|
|
25
|
+
#
|
|
21
26
|
# @param flow_file [String] source .rb flow file (basename used for defaults)
|
|
22
27
|
# @param output [String, nil] value of the --output option
|
|
23
28
|
# @param extension [String] including leading dot, e.g. ".json"
|
|
@@ -63,15 +63,28 @@ module Inquirex
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
# Renders a node. Returns the collected answer, or +nil+ for display verbs.
|
|
66
|
+
#
|
|
67
|
+
# @example Collect an answer for the current step, pre-checking LLM suggestions
|
|
68
|
+
# renderer = Inquirex::TTY::Renderer.new
|
|
69
|
+
# step = engine.current_step
|
|
70
|
+
# answer = renderer.render(step, suggestion: engine.suggestion_for(engine.current_step_id))
|
|
71
|
+
# engine.answer(answer) unless step.display?
|
|
72
|
+
#
|
|
66
73
|
# @param node [Inquirex::Node]
|
|
74
|
+
# @param suggestion [Array, nil] prefill suggestion for the step (option
|
|
75
|
+
# form values, e.g. from Engine#suggestion_for) — multi-select prompts
|
|
76
|
+
# render these choices pre-checked so the user confirms or extends
|
|
67
77
|
# @return [Object, nil]
|
|
68
|
-
def render(node)
|
|
78
|
+
def render(node, suggestion: nil)
|
|
79
|
+
@current_suggestion = suggestion
|
|
69
80
|
if node.display?
|
|
70
81
|
render_display_verb(node)
|
|
71
82
|
nil
|
|
72
83
|
else
|
|
73
84
|
render_collecting(node)
|
|
74
85
|
end
|
|
86
|
+
ensure
|
|
87
|
+
@current_suggestion = nil
|
|
75
88
|
end
|
|
76
89
|
|
|
77
90
|
private
|
|
@@ -177,12 +190,51 @@ module Inquirex
|
|
|
177
190
|
# Integer or float depending on the node's data type.
|
|
178
191
|
def render_number_input(node)
|
|
179
192
|
convert = node.type == :integer ? :int : :float
|
|
180
|
-
|
|
193
|
+
ask_bounded(node, convert:)
|
|
181
194
|
end
|
|
182
195
|
|
|
183
196
|
# Float for currency types.
|
|
184
197
|
def render_currency_input(node)
|
|
185
|
-
|
|
198
|
+
ask_bounded(node, convert: :float)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Asks for a number, enforcing the node's declared bounds.
|
|
202
|
+
#
|
|
203
|
+
# tty-prompt's `in:` re-asks until the answer falls inside the range,
|
|
204
|
+
# which is the terminal's equivalent of the widget's stepper bounds — the
|
|
205
|
+
# user is corrected before the value is ever stored, rather than having it
|
|
206
|
+
# silently clamped afterwards. An unbounded node keeps the plain ask so
|
|
207
|
+
# the prompt text is unchanged for the overwhelmingly common case.
|
|
208
|
+
#
|
|
209
|
+
# @param node [Inquirex::Node] the step being rendered
|
|
210
|
+
# @param convert [Symbol] :int or :float
|
|
211
|
+
# @return [Numeric, nil]
|
|
212
|
+
def ask_bounded(node, convert:)
|
|
213
|
+
return prompt.ask(node.question, convert:) unless node.bounded?
|
|
214
|
+
|
|
215
|
+
prompt.ask(node.question, convert:, in: bounds_range(node)) do |q|
|
|
216
|
+
q.messages[:range?] = "Enter a value #{describe_bounds(node)}."
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The node's bounds as a Range tty-prompt understands. An open end is
|
|
221
|
+
# spelled with infinity rather than omitted, since `in:` wants a Range.
|
|
222
|
+
#
|
|
223
|
+
# @param node [Inquirex::Node]
|
|
224
|
+
# @return [Range]
|
|
225
|
+
def bounds_range(node)
|
|
226
|
+
low = node.min || -Float::INFINITY
|
|
227
|
+
high = node.max || Float::INFINITY
|
|
228
|
+
low..high
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# @param node [Inquirex::Node]
|
|
232
|
+
# @return [String] human phrasing of whichever bounds exist
|
|
233
|
+
def describe_bounds(node)
|
|
234
|
+
return "between #{node.min} and #{node.max}" if node.min && node.max
|
|
235
|
+
return "of #{node.min} or more" if node.min
|
|
236
|
+
|
|
237
|
+
"of #{node.max} or less"
|
|
186
238
|
end
|
|
187
239
|
|
|
188
240
|
# Boolean — tty-prompt yes?.
|
|
@@ -195,9 +247,26 @@ module Inquirex
|
|
|
195
247
|
prompt.select(node.question, select_options(node))
|
|
196
248
|
end
|
|
197
249
|
|
|
198
|
-
# Multiple-choice list (space to toggle, min 1 selection).
|
|
250
|
+
# Multiple-choice list (space to toggle, min 1 selection). Suggested
|
|
251
|
+
# choices (an LLM extraction the user should confirm) render pre-checked.
|
|
199
252
|
def render_multi_select(node)
|
|
200
|
-
prompt.multi_select(node.question, select_options(node), min: 1)
|
|
253
|
+
prompt.multi_select(node.question, select_options(node), min: 1, **multi_select_defaults(node))
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# tty-prompt pre-checks multi_select choices via default: choice NAMES —
|
|
257
|
+
# the labels, not the return values. Suggestions arrive as option form
|
|
258
|
+
# values (that is the wire contract), so translate value → label here;
|
|
259
|
+
# a value with no label entry falls back to itself.
|
|
260
|
+
#
|
|
261
|
+
# @param node [Inquirex::Node]
|
|
262
|
+
# @return [Hash] {} or { default: Array<String> }
|
|
263
|
+
def multi_select_defaults(node)
|
|
264
|
+
suggested = Array(@current_suggestion)
|
|
265
|
+
return {} if suggested.empty?
|
|
266
|
+
|
|
267
|
+
labels = node.respond_to?(:option_labels) ? node.option_labels : nil
|
|
268
|
+
names = suggested.map { |value| labels&.fetch(value.to_s, nil) || value.to_s }
|
|
269
|
+
{ default: names }
|
|
201
270
|
end
|
|
202
271
|
|
|
203
272
|
# Numbered choice menu.
|
|
@@ -12,6 +12,13 @@ module Inquirex
|
|
|
12
12
|
@pastel ||= Pastel.new
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# Module hook that installs the UI helpers on the including class:
|
|
16
|
+
# +tty_box+, +tty_screen+, +pastel+, the TTY::Box wrappers (+frame+,
|
|
17
|
+
# +info+, +success+, +error+, +warning+), the +width+ delegator, and
|
|
18
|
+
# the +box+ / +next_step+ / +sep+ convenience methods.
|
|
19
|
+
#
|
|
20
|
+
# @param base [Class, Module] the class or module including UIHelper
|
|
21
|
+
# @return [void]
|
|
15
22
|
def included(base)
|
|
16
23
|
base.extend(Forwardable)
|
|
17
24
|
base.define_method(:tty_box) { ::TTY::Box }
|
|
@@ -27,6 +34,12 @@ module Inquirex
|
|
|
27
34
|
|
|
28
35
|
base.class_eval do
|
|
29
36
|
# Draw a bordered box with optional title.
|
|
37
|
+
#
|
|
38
|
+
# @param text [String] text to display inside the box
|
|
39
|
+
# @param title [String, nil] optional title on the top border
|
|
40
|
+
# @param bg [Symbol] background color name
|
|
41
|
+
# @param fg [Symbol] foreground color name
|
|
42
|
+
# @return [void]
|
|
30
43
|
def box(text, title: nil, bg: :green, fg: :white) # rubocop:disable Naming/MethodParameterName
|
|
31
44
|
w = [width, 80].min
|
|
32
45
|
args = {
|
|
@@ -41,12 +54,21 @@ module Inquirex
|
|
|
41
54
|
end
|
|
42
55
|
|
|
43
56
|
# Print step progress and separator.
|
|
57
|
+
#
|
|
58
|
+
# @param step_id [Symbol, String] id of the step about to run
|
|
59
|
+
# @param step_number [Integer] 1-based position in the flow
|
|
60
|
+
# @return [void]
|
|
44
61
|
def next_step(step_id, step_number)
|
|
62
|
+
puts
|
|
45
63
|
puts pastel.yellow("Step #{step_number}: #{step_id}")
|
|
46
64
|
sep(:yellow, "━")
|
|
47
65
|
end
|
|
48
66
|
|
|
49
67
|
# Print a full-width separator in the given color.
|
|
68
|
+
#
|
|
69
|
+
# @param color [Symbol] Pastel color name
|
|
70
|
+
# @param char [String] character repeated across the line
|
|
71
|
+
# @return [void]
|
|
50
72
|
def sep(color = :yellow, char = "━")
|
|
51
73
|
puts pastel.send(color, char * 80)
|
|
52
74
|
end
|
data/lib/inquirex/tty/version.rb
CHANGED
data/lib/inquirex/tty.rb
CHANGED
|
@@ -14,7 +14,15 @@ require "yaml"
|
|
|
14
14
|
require "time"
|
|
15
15
|
require "shellwords"
|
|
16
16
|
require "tempfile"
|
|
17
|
+
require "etc"
|
|
18
|
+
require "socket"
|
|
19
|
+
require "net/http"
|
|
20
|
+
require "uri"
|
|
21
|
+
require "ostruct"
|
|
17
22
|
|
|
23
|
+
# Top-level namespace shared by every gem in the Inquirex family.
|
|
24
|
+
# The core gem defines the DSL, rules, and engine; this gem adds the
|
|
25
|
+
# terminal adapter under {Inquirex::TTY}.
|
|
18
26
|
module Inquirex
|
|
19
27
|
# Terminal adapter for Inquirex flows. Renders questions as interactive
|
|
20
28
|
# TTY prompts via tty-prompt, mapping each data type to the appropriate
|