inquirex-tty 0.4.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 +7 -0
- data/.envrc +3 -0
- data/.relaxed_rubocop.yml +153 -0
- data/.secrets.baseline +127 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +530 -0
- data/Rakefile +36 -0
- data/docs/badges/coverage_badge.svg +21 -0
- data/examples/01_hello_world.rb +32 -0
- data/examples/02_yes_or_no.rb +33 -0
- data/examples/03_food_preferences.png +0 -0
- data/examples/03_food_preferences.rb +65 -0
- data/examples/04_event_registration.rb +86 -0
- data/examples/05_job_application.rb +122 -0
- data/examples/06_health_assessment.rb +173 -0
- data/examples/07_loan_application.rb +217 -0
- data/examples/08_tax_preparer.rb +195 -0
- data/examples/09_tax_preparer_llm.rb +151 -0
- data/examples/10_answers.json +53 -0
- data/examples/10_real_tax_preparer.rb +604 -0
- data/examples/README.md +50 -0
- data/exe/inquirex +24 -0
- data/justfile +56 -0
- data/lefthook.yml +36 -0
- data/lib/inquirex/tty/commands/export.rb +81 -0
- data/lib/inquirex/tty/commands/graph.rb +116 -0
- data/lib/inquirex/tty/commands/run.rb +193 -0
- data/lib/inquirex/tty/commands/validate.rb +108 -0
- data/lib/inquirex/tty/commands/version.rb +19 -0
- data/lib/inquirex/tty/commands.rb +19 -0
- data/lib/inquirex/tty/flow_loader.rb +41 -0
- data/lib/inquirex/tty/output_path.rb +66 -0
- data/lib/inquirex/tty/renderer.rb +245 -0
- data/lib/inquirex/tty/ui_helper.rb +58 -0
- data/lib/inquirex/tty/version.rb +7 -0
- data/lib/inquirex/tty.rb +41 -0
- data/sig/inquirex/tty.rbs +6 -0
- metadata +197 -0
data/justfile
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# © 2026 Konstantin Gredeskoul
|
|
2
|
+
|
|
3
|
+
set shell := ["bash", "-eu", "-o", "pipefail", "-c"]
|
|
4
|
+
|
|
5
|
+
repo := 'git@github.com:inquirex/inquirex-tty.git'
|
|
6
|
+
version := `grep VERSION lib/inquirex/tty/version.rb | awk '{print $3}' | tr -d '"' | tr -d '\n'`
|
|
7
|
+
|
|
8
|
+
# Run full test suite with coverage
|
|
9
|
+
test:
|
|
10
|
+
bundle exec rspec --format documentation
|
|
11
|
+
|
|
12
|
+
# Run RuboCop linter
|
|
13
|
+
lint:
|
|
14
|
+
bundle exec rubocop
|
|
15
|
+
|
|
16
|
+
# Auto-correct RuboCop offenses
|
|
17
|
+
format:
|
|
18
|
+
bundle exec rubocop -A
|
|
19
|
+
|
|
20
|
+
# Run a flow interactively
|
|
21
|
+
run flow_file:
|
|
22
|
+
bundle exec exe/inquirex-tty run {{flow_file}}
|
|
23
|
+
|
|
24
|
+
# Validate a flow definition
|
|
25
|
+
validate flow_file:
|
|
26
|
+
bundle exec exe/inquirex-tty validate {{flow_file}}
|
|
27
|
+
|
|
28
|
+
# Export a flow as a Mermaid diagram (stdout)
|
|
29
|
+
graph flow_file:
|
|
30
|
+
bundle exec exe/inquirex-tty graph {{flow_file}}
|
|
31
|
+
|
|
32
|
+
# Validate all examples
|
|
33
|
+
examples:
|
|
34
|
+
#!/usr/bin/env bash
|
|
35
|
+
set -e
|
|
36
|
+
for f in examples/*.rb; do
|
|
37
|
+
echo "=== $f ==="
|
|
38
|
+
bundle exec exe/inquirex-tty validate "$f"
|
|
39
|
+
echo ""
|
|
40
|
+
done
|
|
41
|
+
|
|
42
|
+
# Run all tests then lint
|
|
43
|
+
ci: test lint
|
|
44
|
+
|
|
45
|
+
alias check-all := ci
|
|
46
|
+
|
|
47
|
+
version:
|
|
48
|
+
@echo "{{ version }}"
|
|
49
|
+
|
|
50
|
+
# Tag v{{ version }}, publish the GH release, & refresh the Homebrew tap.
|
|
51
|
+
release:
|
|
52
|
+
git fetch --tags
|
|
53
|
+
git tag -f "v{{ version }}"
|
|
54
|
+
git push -f --tags
|
|
55
|
+
gh release delete -y "v{{ version }}" --repo {{ repo }} 2>/dev/null || true
|
|
56
|
+
gh release create "v{{ version }}" --generate-notes --repo {{ repo }}
|
data/lefthook.yml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
output:
|
|
2
|
+
- summary
|
|
3
|
+
- failure
|
|
4
|
+
|
|
5
|
+
pre-commit:
|
|
6
|
+
parallel: true
|
|
7
|
+
jobs:
|
|
8
|
+
- name: lint
|
|
9
|
+
run: bundle exec rubocop -c .rubocop.yml {staged_files}
|
|
10
|
+
glob: "*.{rb,Gemfile}"
|
|
11
|
+
stage_fixed: true
|
|
12
|
+
|
|
13
|
+
- name: check for conflict markers and whitespace issues
|
|
14
|
+
run: git --no-pager diff --check
|
|
15
|
+
|
|
16
|
+
# If tests take >1 second, move this (or just the long-running tests) to pre-push.
|
|
17
|
+
- name: run tests
|
|
18
|
+
run: just test
|
|
19
|
+
|
|
20
|
+
- name: fix rubocop formatting issues
|
|
21
|
+
run: bundle exec rubocop -a {staged_files}
|
|
22
|
+
glob: "*.{rb,Gemfile,gemspec}"
|
|
23
|
+
stage_fixed: true
|
|
24
|
+
|
|
25
|
+
- name: spell check
|
|
26
|
+
run: codespell {staged_files}
|
|
27
|
+
glob: "*.{rb,md,gemspec}"
|
|
28
|
+
exclude: "examples/*"
|
|
29
|
+
|
|
30
|
+
- name: format markdown
|
|
31
|
+
run: mdformat {staged_files}
|
|
32
|
+
glob: "*.md"
|
|
33
|
+
stage_fixed: true
|
|
34
|
+
|
|
35
|
+
- name: scan for secrets
|
|
36
|
+
run: detect-secrets-hook --baseline .secrets.baseline
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module TTY
|
|
5
|
+
module Commands
|
|
6
|
+
# Exports a flow definition as JSON or YAML (stdout or file).
|
|
7
|
+
#
|
|
8
|
+
# Examples:
|
|
9
|
+
# inquirex export examples/08_tax_preparer.rb # JSON to stdout
|
|
10
|
+
# inquirex export examples/08_tax_preparer.rb -f yml # YAML to stdout
|
|
11
|
+
# inquirex export examples/08_tax_preparer.rb -o . # write 08_tax_preparer.json to cwd
|
|
12
|
+
# inquirex export examples/08_tax_preparer.rb -f yml -o . # write 08_tax_preparer.yml to cwd
|
|
13
|
+
# inquirex export examples/08_tax_preparer.rb -o out.json # write to out.json
|
|
14
|
+
class Export < Dry::CLI::Command
|
|
15
|
+
LONG_DESCRIPTION = "Export a flow definition as JSON or YAML.\n\n" \
|
|
16
|
+
"Example:\n inquirex export examples/08_tax_preparer.rb -f yml -o ."
|
|
17
|
+
|
|
18
|
+
SHORT_DESCRIPTION = "Export a flow definition as JSON or YAML ."
|
|
19
|
+
|
|
20
|
+
if ARGV[0] == "export"
|
|
21
|
+
desc LONG_DESCRIPTION
|
|
22
|
+
else
|
|
23
|
+
desc SHORT_DESCRIPTION
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
argument :flow_file,
|
|
27
|
+
required: true,
|
|
28
|
+
desc: "Path to flow definition (.rb file)"
|
|
29
|
+
option :format,
|
|
30
|
+
aliases: ["-f"],
|
|
31
|
+
default: "json",
|
|
32
|
+
values: %w[json yaml yml],
|
|
33
|
+
desc: "Output format"
|
|
34
|
+
option :output,
|
|
35
|
+
aliases: ["-o"],
|
|
36
|
+
desc: "Output file or directory (default: stdout)"
|
|
37
|
+
|
|
38
|
+
# @param flow_file [String]
|
|
39
|
+
# @param options [Hash]
|
|
40
|
+
# @return [void]
|
|
41
|
+
def call(flow_file:, **options)
|
|
42
|
+
definition = FlowLoader.load(flow_file)
|
|
43
|
+
format = normalize_format(options[:format])
|
|
44
|
+
content = serialize(definition, format)
|
|
45
|
+
write(content, flow_file, options[:output], extension_for(format))
|
|
46
|
+
rescue Inquirex::TTY::Error => e
|
|
47
|
+
warn "Error: #{e.message}"
|
|
48
|
+
exit 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def normalize_format(format)
|
|
54
|
+
%w[yaml yml].include?(format.to_s) ? "yaml" : "json"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def serialize(definition, format)
|
|
58
|
+
case format
|
|
59
|
+
when "yaml" then definition.to_h.to_yaml
|
|
60
|
+
else JSON.pretty_generate(definition.to_h)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def extension_for(format)
|
|
65
|
+
format == "yaml" ? ".yml" : ".json"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def write(content, flow_file, output, extension)
|
|
69
|
+
path = OutputPath.resolve(flow_file, output, extension)
|
|
70
|
+
unless path
|
|
71
|
+
$stdout.puts content
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
File.write(path, content)
|
|
76
|
+
warn "Exported to #{path}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module TTY
|
|
5
|
+
module Commands
|
|
6
|
+
# Exports a flow definition as a Mermaid flowchart (stdout or file).
|
|
7
|
+
class Graph < Dry::CLI::Command
|
|
8
|
+
LONG_DESCRIPTION = "Export a flow definition as a Mermaid diagram source, an image, or both.\n " \
|
|
9
|
+
"Image generation requires mermaid-cli (npm install -g @mermaid-js/mermaid-cli)\n " \
|
|
10
|
+
"which this gem will attempt to install for you if mmdc command is not available.\n\n" \
|
|
11
|
+
"Example:\n inquirex graph qualify_dsl.rb --format both --output ~/Desktop --open"
|
|
12
|
+
|
|
13
|
+
SHORT_DESCRIPTION = "Export a flow definition as a Mermaid diagram source, an image, or both."
|
|
14
|
+
|
|
15
|
+
if ARGV[0] == "graph"
|
|
16
|
+
desc LONG_DESCRIPTION
|
|
17
|
+
else
|
|
18
|
+
desc SHORT_DESCRIPTION
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
argument :flow_file,
|
|
22
|
+
required: true,
|
|
23
|
+
desc: "Path to flow definition (.rb file)"
|
|
24
|
+
option :output,
|
|
25
|
+
aliases: ["-o"],
|
|
26
|
+
desc: "Output file or a directory (default: stdout)"
|
|
27
|
+
option :format,
|
|
28
|
+
aliases: ["-f"],
|
|
29
|
+
default: "source",
|
|
30
|
+
values: %w[source image both],
|
|
31
|
+
desc: "Output format"
|
|
32
|
+
option :open,
|
|
33
|
+
aliases: ["-p"],
|
|
34
|
+
default: false,
|
|
35
|
+
type: :boolean,
|
|
36
|
+
desc: "Open the SVG in a system viewer"
|
|
37
|
+
|
|
38
|
+
# @param flow_file [String]
|
|
39
|
+
# @param options [Hash]
|
|
40
|
+
# @return [void]
|
|
41
|
+
def call(flow_file:, **options)
|
|
42
|
+
definition = FlowLoader.load(flow_file)
|
|
43
|
+
source = Inquirex::Graph::MermaidExporter.new(definition).export
|
|
44
|
+
format = options[:format] || "source"
|
|
45
|
+
output = options[:output]
|
|
46
|
+
|
|
47
|
+
case format
|
|
48
|
+
when "source"
|
|
49
|
+
write_source(source, OutputPath.resolve(flow_file, output, ".mmd"))
|
|
50
|
+
when "image"
|
|
51
|
+
write_image(source, OutputPath.resolve_with_default(flow_file, output, ".png"), options[:open])
|
|
52
|
+
when "both"
|
|
53
|
+
write_source(source, OutputPath.resolve(flow_file, output, ".mmd"))
|
|
54
|
+
write_image(source, OutputPath.resolve_with_default(flow_file, output, ".png"), options[:open])
|
|
55
|
+
end
|
|
56
|
+
rescue Inquirex::TTY::Error => e
|
|
57
|
+
warn "Error: #{e.message}"
|
|
58
|
+
exit 1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def write_source(source, output_path)
|
|
64
|
+
unless output_path
|
|
65
|
+
$stdout.puts source
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
File.write(output_path, source)
|
|
69
|
+
warn "Diagram written to #{output_path}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def write_image(source, output_path, open_file)
|
|
73
|
+
ensure_mermaid_cli_installed!
|
|
74
|
+
|
|
75
|
+
Tempfile.create(%w[inquirex-graph .mmd]) do |temp|
|
|
76
|
+
temp.write(source)
|
|
77
|
+
temp.flush
|
|
78
|
+
run_system_command!(
|
|
79
|
+
"mmdc -w 2000 -i #{Shellwords.escape(temp.path)} -o #{Shellwords.escape(output_path)}",
|
|
80
|
+
"Failed to generate image (mmdc)"
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
warn "Diagram written to #{output_path}"
|
|
85
|
+
open_image_file(output_path) if open_file
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def ensure_mermaid_cli_installed!
|
|
89
|
+
return if command_available?("mmdc")
|
|
90
|
+
|
|
91
|
+
warn "Installing @mermaid-js/mermaid-cli..."
|
|
92
|
+
installed = system("npm install -g @mermaid-js/mermaid-cli")
|
|
93
|
+
return if installed && command_available?("mmdc")
|
|
94
|
+
|
|
95
|
+
raise Inquirex::TTY::Error,
|
|
96
|
+
"Could not install mermaid-cli. Run: npm install -g @mermaid-js/mermaid-cli"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def open_image_file(filename)
|
|
100
|
+
run_system_command!(
|
|
101
|
+
"open #{Shellwords.escape(filename)}",
|
|
102
|
+
"Failed to open #{filename}"
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def command_available?(command)
|
|
107
|
+
system("command -v #{command} > /dev/null 2>&1")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def run_system_command!(command, error_message)
|
|
111
|
+
raise Inquirex::TTY::Error, error_message unless system(command)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module TTY
|
|
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.
|
|
8
|
+
class Run < Dry::CLI::Command
|
|
9
|
+
desc "Run a flow definition interactively"
|
|
10
|
+
|
|
11
|
+
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"
|
|
13
|
+
|
|
14
|
+
# @param flow_file [String]
|
|
15
|
+
# @param options [Hash]
|
|
16
|
+
# @return [void]
|
|
17
|
+
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 # rubocop:disable Style/StderrPuts
|
|
26
|
+
end
|
|
27
|
+
rescue Inquirex::TTY::Error => e
|
|
28
|
+
error(e.message)
|
|
29
|
+
exit 1
|
|
30
|
+
rescue Inquirex::Errors::Error => e
|
|
31
|
+
error("Engine error: #{e.message}")
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# @param flow_file [String]
|
|
38
|
+
# @return [Inquirex::Engine]
|
|
39
|
+
def run_flow(flow_file)
|
|
40
|
+
load_dotenv!
|
|
41
|
+
definition = FlowLoader.load(flow_file)
|
|
42
|
+
engine = Inquirex::Engine.new(definition)
|
|
43
|
+
renderer = Renderer.new
|
|
44
|
+
adapter = build_llm_adapter
|
|
45
|
+
|
|
46
|
+
show_banner(definition)
|
|
47
|
+
|
|
48
|
+
until engine.finished?
|
|
49
|
+
step = engine.current_step
|
|
50
|
+
next_step(engine.current_step_id, engine.history.length + 1)
|
|
51
|
+
|
|
52
|
+
if step.respond_to?(:llm_verb?) && step.llm_verb?
|
|
53
|
+
run_llm_step(engine, step, adapter, renderer)
|
|
54
|
+
elsif step.display?
|
|
55
|
+
renderer.render(step)
|
|
56
|
+
engine.advance
|
|
57
|
+
else
|
|
58
|
+
engine.answer(renderer.render(step))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
engine
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Calls the LLM adapter for a clarify/describe/summarize/detour step,
|
|
66
|
+
# stores the result as the step's answer, and for clarify verbs splats
|
|
67
|
+
# extracted fields into top-level answers via Engine#prefill! so that
|
|
68
|
+
# downstream `skip_if not_empty(:key)` rules will fire.
|
|
69
|
+
def run_llm_step(engine, step, adapter, renderer)
|
|
70
|
+
renderer.thinking("🧠 Thinking — asking #{adapter_label(adapter)} to extract structured data…")
|
|
71
|
+
result = adapter.call(step, engine.answers)
|
|
72
|
+
engine.answer(result)
|
|
73
|
+
if step.verb == :clarify && result.is_a?(Hash)
|
|
74
|
+
engine.prefill!(result)
|
|
75
|
+
renderer.show_extraction(result)
|
|
76
|
+
else
|
|
77
|
+
puts "\n#{result.is_a?(String) ? result : JSON.pretty_generate(result)}\n"
|
|
78
|
+
end
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
error("LLM step #{step.id} failed: #{e.class}: #{e.message}")
|
|
81
|
+
raise
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Adapter preference order (first match wins):
|
|
85
|
+
# INQUIREX_LLM_ADAPTER=null → NullAdapter (forced offline)
|
|
86
|
+
# INQUIREX_LLM_ADAPTER=anthropic → AnthropicAdapter (fails if no key)
|
|
87
|
+
# INQUIREX_LLM_ADAPTER=openai → OpenAIAdapter (fails if no key)
|
|
88
|
+
# ANTHROPIC_API_KEY present → AnthropicAdapter
|
|
89
|
+
# OPENAI_API_KEY present → OpenAIAdapter
|
|
90
|
+
# otherwise → NullAdapter
|
|
91
|
+
def build_llm_adapter
|
|
92
|
+
forced = ENV["INQUIREX_LLM_ADAPTER"].to_s.downcase
|
|
93
|
+
return Inquirex::LLM::NullAdapter.new if forced == "null"
|
|
94
|
+
return Inquirex::LLM::AnthropicAdapter.new if forced == "anthropic"
|
|
95
|
+
return Inquirex::LLM::OpenAIAdapter.new if forced == "openai"
|
|
96
|
+
|
|
97
|
+
if env_key?("ANTHROPIC_API_KEY") && defined?(Inquirex::LLM::AnthropicAdapter)
|
|
98
|
+
Inquirex::LLM::AnthropicAdapter.new
|
|
99
|
+
elsif env_key?("OPENAI_API_KEY") && defined?(Inquirex::LLM::OpenAIAdapter)
|
|
100
|
+
Inquirex::LLM::OpenAIAdapter.new
|
|
101
|
+
else
|
|
102
|
+
Inquirex::LLM::NullAdapter.new
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def env_key?(name)
|
|
107
|
+
v = ENV.fetch(name, nil)
|
|
108
|
+
!v.nil? && !v.empty?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def adapter_label(adapter)
|
|
112
|
+
case adapter
|
|
113
|
+
when Inquirex::LLM::AnthropicAdapter then "Claude"
|
|
114
|
+
when Inquirex::LLM::OpenAIAdapter then "GPT"
|
|
115
|
+
else "the null adapter"
|
|
116
|
+
end
|
|
117
|
+
rescue NameError
|
|
118
|
+
"the null adapter"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Minimal .env loader. Walks up from the cwd and the flow file's
|
|
122
|
+
# directory, loading every .env found along the way. Later loads do
|
|
123
|
+
# not overwrite earlier values, and nothing overrides keys already
|
|
124
|
+
# set in the real environment.
|
|
125
|
+
def load_dotenv!
|
|
126
|
+
seen = {}
|
|
127
|
+
[Dir.pwd, File.dirname(File.expand_path(ARGV.last.to_s))].uniq.each do |start|
|
|
128
|
+
walk_ancestors(start).each do |dir|
|
|
129
|
+
path = File.join(dir, ".env")
|
|
130
|
+
next if seen[path] || !File.file?(path)
|
|
131
|
+
|
|
132
|
+
seen[path] = true
|
|
133
|
+
load_env_file(path)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def walk_ancestors(dir)
|
|
139
|
+
ancestors = []
|
|
140
|
+
current = File.expand_path(dir)
|
|
141
|
+
loop do
|
|
142
|
+
ancestors << current
|
|
143
|
+
parent = File.dirname(current)
|
|
144
|
+
break if parent == current
|
|
145
|
+
|
|
146
|
+
current = parent
|
|
147
|
+
end
|
|
148
|
+
ancestors
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def load_env_file(path)
|
|
152
|
+
File.foreach(path) do |line|
|
|
153
|
+
next if line.strip.empty? || line.start_with?("#")
|
|
154
|
+
|
|
155
|
+
key, _, value = line.strip.partition("=")
|
|
156
|
+
next if key.empty?
|
|
157
|
+
# Preserve shell-set values, but fill in keys that are unset or
|
|
158
|
+
# set to the empty string.
|
|
159
|
+
next if ENV.key?(key) && !ENV[key].to_s.empty?
|
|
160
|
+
|
|
161
|
+
ENV[key] = value.gsub(/\A["']|["']\z/, "")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @param definition [Inquirex::Definition]
|
|
166
|
+
# @return [void]
|
|
167
|
+
def show_banner(definition)
|
|
168
|
+
title = definition.meta&.fetch(:title, nil) || "Inquirex"
|
|
169
|
+
font = ::TTY::Font.new(:standard)
|
|
170
|
+
title.split.each do |word|
|
|
171
|
+
puts pastel.bright_yellow(font.write(word))
|
|
172
|
+
end
|
|
173
|
+
sep(:green, "❚")
|
|
174
|
+
rescue StandardError
|
|
175
|
+
puts box(definition.meta&.fetch(:title, nil) || "Inquirex Wizard")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @param flow_file [String]
|
|
179
|
+
# @param engine [Inquirex::Engine]
|
|
180
|
+
# @return [Hash]
|
|
181
|
+
def build_result(flow_file, engine)
|
|
182
|
+
{
|
|
183
|
+
flow_file: flow_file,
|
|
184
|
+
path_taken: engine.history,
|
|
185
|
+
answers: engine.answers,
|
|
186
|
+
steps_completed: engine.history.length,
|
|
187
|
+
completed_at: Time.now.iso8601
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module TTY
|
|
5
|
+
module Commands
|
|
6
|
+
# Validates a flow definition file: start step exists, all transition targets
|
|
7
|
+
# are known, and every step is reachable from the start step.
|
|
8
|
+
class Validate < Dry::CLI::Command
|
|
9
|
+
desc "Validate a flow definition file"
|
|
10
|
+
|
|
11
|
+
argument :flow_file, required: true, desc: "Path to flow definition (.rb file)"
|
|
12
|
+
|
|
13
|
+
# @param flow_file [String]
|
|
14
|
+
# @return [void]
|
|
15
|
+
def call(flow_file:, **)
|
|
16
|
+
definition = FlowLoader.load(flow_file)
|
|
17
|
+
errors = validate_definition(definition)
|
|
18
|
+
|
|
19
|
+
if errors.empty?
|
|
20
|
+
print_success(definition)
|
|
21
|
+
else
|
|
22
|
+
print_errors(errors)
|
|
23
|
+
exit 1
|
|
24
|
+
end
|
|
25
|
+
rescue Inquirex::TTY::Error => e
|
|
26
|
+
warn "Error: #{e.message}"
|
|
27
|
+
exit 1
|
|
28
|
+
rescue Inquirex::Errors::Error => e
|
|
29
|
+
warn "Definition error: #{e.message}"
|
|
30
|
+
exit 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def print_success(definition)
|
|
36
|
+
puts "Flow definition is valid!"
|
|
37
|
+
puts " ID: #{definition.id || "(none)"}"
|
|
38
|
+
puts " Version: #{definition.version}"
|
|
39
|
+
puts " Start step: #{definition.start_step_id}"
|
|
40
|
+
puts " Total steps: #{definition.step_ids.length}"
|
|
41
|
+
puts " Steps: #{definition.step_ids.join(", ")}"
|
|
42
|
+
print_meta(definition.meta) if definition.meta && !definition.meta.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def print_meta(meta)
|
|
46
|
+
puts " Title: #{meta[:title]}" if meta[:title]
|
|
47
|
+
puts " Subtitle: #{meta[:subtitle]}" if meta[:subtitle]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def print_errors(errors)
|
|
51
|
+
warn "Flow definition has #{errors.length} error(s):"
|
|
52
|
+
errors.each { |e| warn " - #{e}" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_definition(definition)
|
|
56
|
+
errors = []
|
|
57
|
+
validate_start_step(definition, errors)
|
|
58
|
+
validate_transition_targets(definition, errors)
|
|
59
|
+
validate_reachability(definition, errors)
|
|
60
|
+
errors
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validate_start_step(definition, errors)
|
|
64
|
+
return if definition.step_ids.include?(definition.start_step_id)
|
|
65
|
+
|
|
66
|
+
errors << "Start step :#{definition.start_step_id} not found in steps"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_transition_targets(definition, errors)
|
|
70
|
+
definition.step_ids.each do |step_id|
|
|
71
|
+
step = definition.step(step_id)
|
|
72
|
+
step.transitions.each do |transition|
|
|
73
|
+
next if definition.step_ids.include?(transition.target)
|
|
74
|
+
|
|
75
|
+
errors << "Step :#{step_id} transitions to unknown step :#{transition.target}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_reachability(definition, errors)
|
|
81
|
+
reachable = find_reachable_steps(definition)
|
|
82
|
+
orphans = definition.step_ids - reachable
|
|
83
|
+
orphans.each { |o| errors << "Step :#{o} is unreachable from :#{definition.start_step_id}" }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def find_reachable_steps(definition)
|
|
87
|
+
visited = Set.new
|
|
88
|
+
queue = [definition.start_step_id]
|
|
89
|
+
known = definition.step_ids
|
|
90
|
+
|
|
91
|
+
until queue.empty?
|
|
92
|
+
current = queue.shift
|
|
93
|
+
next if visited.include?(current)
|
|
94
|
+
|
|
95
|
+
visited << current
|
|
96
|
+
next unless known.include?(current)
|
|
97
|
+
|
|
98
|
+
definition.step(current).transitions.each do |t|
|
|
99
|
+
queue << t.target if known.include?(t.target)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
visited.to_a
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module TTY
|
|
5
|
+
module Commands
|
|
6
|
+
# Prints inquirex-tty and inquirex gem versions to stdout.
|
|
7
|
+
class Version < Dry::CLI::Command
|
|
8
|
+
desc "Print version information"
|
|
9
|
+
|
|
10
|
+
# @param **_ [Hash] ignored options
|
|
11
|
+
# @return [void]
|
|
12
|
+
def call(**)
|
|
13
|
+
puts "inquirex-tty #{Inquirex::TTY::VERSION}"
|
|
14
|
+
puts "inquirex #{Inquirex::VERSION}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module TTY
|
|
5
|
+
# Dry::CLI registry for inquirex-tty subcommands.
|
|
6
|
+
# UIHelper is mixed into every command so box/sep/next_step are available.
|
|
7
|
+
module Commands
|
|
8
|
+
extend Dry::CLI::Registry
|
|
9
|
+
|
|
10
|
+
::Dry::CLI::Command.include(UIHelper)
|
|
11
|
+
|
|
12
|
+
register "run", Run
|
|
13
|
+
register "graph", Graph
|
|
14
|
+
register "export", Export
|
|
15
|
+
register "validate", Validate
|
|
16
|
+
register "version", Version
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module TTY
|
|
5
|
+
# Loads a flow definition from a Ruby file by evaluating it in a top-level
|
|
6
|
+
# binding. The file is expected to call +Inquirex.define+ and return an
|
|
7
|
+
# +Inquirex::Definition+.
|
|
8
|
+
class FlowLoader
|
|
9
|
+
# @param path [String] path to a .rb flow definition file
|
|
10
|
+
# @return [Inquirex::Definition]
|
|
11
|
+
# @raise [Inquirex::TTY::Error] if file is missing, not .rb, or has syntax errors
|
|
12
|
+
def self.load(path)
|
|
13
|
+
new(path).load
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @param path [String] path (will be expanded)
|
|
17
|
+
def initialize(path)
|
|
18
|
+
@path = File.expand_path(path)
|
|
19
|
+
validate_path!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Inquirex::Definition]
|
|
23
|
+
# @raise [Inquirex::TTY::Error] on syntax error
|
|
24
|
+
def load
|
|
25
|
+
content = File.read(@path)
|
|
26
|
+
# rubocop:disable Security/Eval
|
|
27
|
+
eval(content, TOPLEVEL_BINDING.dup, @path, 1)
|
|
28
|
+
# rubocop:enable Security/Eval
|
|
29
|
+
rescue SyntaxError => e
|
|
30
|
+
raise Inquirex::TTY::Error, "Syntax error in #{@path}: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def validate_path!
|
|
36
|
+
raise Inquirex::TTY::Error, "File not found: #{@path}" unless File.exist?(@path)
|
|
37
|
+
raise Inquirex::TTY::Error, "Not a .rb file: #{@path}" unless @path.end_with?(".rb")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|