flowengine-cli 0.1.0 → 0.1.2

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.
@@ -0,0 +1,33 @@
1
+ # This is the first example in the README
2
+
3
+ FlowEngine.define do
4
+ start :filing_status
5
+
6
+ step :filing_status do
7
+ type :single_select
8
+ question "What is your filing status?"
9
+ options %w[Single Married HeadOfHousehold]
10
+ transition to: :income_types
11
+ end
12
+
13
+ step :income_types do
14
+ type :multi_select
15
+ question "Select all income types that apply:"
16
+ options %w[W2 1099 Business Investment Rental]
17
+ transition to: :business_details, if_rule: contains(:income_types, "Business")
18
+ transition to: :summary
19
+ end
20
+
21
+ step :business_details do
22
+ type :number_matrix
23
+ question "How many of each business type?"
24
+ fields %w[LLC SCorp CCorp]
25
+ transition to: :summary
26
+ end
27
+
28
+ step :summary do
29
+ type :header
30
+ decorations("Success!")
31
+ question "Thank you for completing the intake!"
32
+ end
33
+ end
data/justfile CHANGED
@@ -2,10 +2,18 @@ set shell := ["bash", "-c"]
2
2
 
3
3
  set dotenv-load
4
4
 
5
+ [no-exit-message]
6
+ recipes:
7
+ @just --choose
8
+
5
9
  # Boot the app
6
10
  test:
11
+ @echo "Ensuring bundle install is up to date..."
7
12
  @bundle check || bundle install -j 12
13
+ @echo "Running specs..."
8
14
  @bundle exec rspec --format documentation
15
+ @echo "Running rubocop..."
16
+ @bundle exec rubocop
9
17
 
10
18
  # Setup Ruby dependencies
11
19
  setup-ruby:
@@ -29,15 +37,44 @@ setup: setup-node setup-ruby
29
37
  cli *ARGS:
30
38
  ./cli {{ARGS}}
31
39
 
32
- validate-valid-json:
33
- ./cli validate-json -f VALID-JSON/sample.json -s VALID-JSON/schema.json
40
+ # Run a flow interactively: just run examples/01_hello_world.rb
41
+ run file *ARGS:
42
+ @bundle exec exe/flowengine-cli run {{file}} {{ARGS}}
43
+
44
+ # Export a flow as a Mermaid diagram: just graph examples/07_loan_application.rb
45
+ graph file *ARGS:
46
+ @bundle exec exe/flowengine-cli graph {{file}} {{ARGS}}
47
+
48
+ # Validate a flow definition: just validate examples/04_event_registration.rb
49
+ validate file:
50
+ @bundle exec exe/flowengine-cli validate {{file}}
34
51
 
52
+ # List available example flows
53
+ examples:
54
+ #!/usr/bin/env bash
55
+ if command fd>/dev/null 2>&1; then
56
+ fd --type file '.rb$' examples/
57
+ else
58
+ find examples -type f -name '*.rb'
59
+ fi
60
+ echo
61
+ echo "To run an example, type 'flow run examples/<example-file.rb>'"
62
+
63
+ examples-list:
64
+
65
+ # Formats minor syntax issues and writes the rest into a TODO file
35
66
  format:
36
67
  @bundle exec rubocop -a
37
68
  @bundle exec rubocop --auto-gen-config
38
69
 
70
+ # Run all linters, in this case just rubocop
39
71
  lint:
40
72
  @bundle exec rubocop
41
73
 
74
+ # Generates YARD documentation into the ./doc folder, and opens ./doc/index.html
75
+ doc:
76
+ @rake doc
77
+ @open doc/index.html
78
+
42
79
  check-all: lint test
43
80
 
@@ -3,6 +3,7 @@
3
3
  module FlowEngine
4
4
  module CLI
5
5
  module Commands
6
+ # Exports a flow definition as a Mermaid flowchart (stdout or file).
6
7
  class Graph < Dry::CLI::Command
7
8
  desc "Export a flow definition as a Mermaid diagram"
8
9
 
@@ -10,6 +11,9 @@ module FlowEngine
10
11
  option :output, aliases: ["-o"], desc: "Output file (default: stdout)"
11
12
  option :format, default: "mermaid", desc: "Output format (mermaid)"
12
13
 
14
+ # @param flow_file [String] path to the flow definition .rb file
15
+ # @param options [Hash] :output => path to write diagram, :format => "mermaid"
16
+ # @return [void]
13
17
  def call(flow_file:, **options)
14
18
  definition = FlowLoader.load(flow_file)
15
19
 
@@ -7,45 +7,56 @@ require "tty-screen"
7
7
  module FlowEngine
8
8
  module CLI
9
9
  module Commands
10
+ # Runs a flow definition interactively via TTY prompts and writes the
11
+ # collected answers (and metadata) as JSON to stdout or a file.
10
12
  class Run < Dry::CLI::Command
11
13
  desc "Run a flow definition interactively"
12
14
 
13
15
  argument :flow_file, required: true, desc: "Path to flow definition (.rb file)"
14
16
  option :output, aliases: ["-o"], desc: "Output file for JSON results"
15
17
 
18
+ # @param flow_file [String] path to the flow definition .rb file
19
+ # @param options [Hash] :output => path to write JSON (optional)
20
+ # @return [void]
16
21
  def call(flow_file:, **options)
17
22
  engine = run_flow(flow_file)
18
23
  json_output = JSON.pretty_generate(build_result(flow_file, engine))
19
24
 
20
- display_success("Flow completed!")
21
- puts json_output
22
-
23
- write_output(options[:output], json_output) if options[:output]
25
+ if options[:output]
26
+ write_output(options[:output], json_output)
27
+ else
28
+ $stderr.puts json_output # rubocop:disable Style/StderrPuts
29
+ end
24
30
  rescue FlowEngine::CLI::Error => e
25
- display_error(e.message)
31
+ error(e.message)
26
32
  exit 1
27
33
  rescue FlowEngine::Error => e
28
- display_error("Engine error: #{e.message}")
34
+ error("Engine error: #{e.message}")
29
35
  exit 1
30
36
  end
31
37
 
32
38
  private
33
39
 
40
+ # @param flow_file [String] path to flow definition
41
+ # @return [FlowEngine::Engine] engine after completion
34
42
  def run_flow(flow_file)
35
43
  definition = FlowLoader.load(flow_file)
36
44
  engine = FlowEngine::Engine.new(definition)
37
45
  renderer = Renderer.new
38
46
 
39
- display_header("FlowEngine Interactive Wizard")
47
+ box("FlowEngine Interactive Wizard")
40
48
 
41
49
  until engine.finished?
42
- display_step_indicator(engine.current_step_id, engine.history.length)
50
+ next_step(engine.current_step_id, engine.history.length)
43
51
  engine.answer(renderer.render(engine.current_step))
44
52
  end
45
53
 
46
54
  engine
47
55
  end
48
56
 
57
+ # @param flow_file [String] path used to load the flow
58
+ # @param engine [FlowEngine::Engine] completed engine
59
+ # @return [Hash] result hash with flow_file, path_taken, answers, etc.
49
60
  def build_result(flow_file, engine)
50
61
  {
51
62
  flow_file: flow_file,
@@ -56,30 +67,13 @@ module FlowEngine
56
67
  }
57
68
  end
58
69
 
70
+ # @param path [String] output file path
71
+ # @param json_output [String] JSON string to write
72
+ # @return [void]
59
73
  def write_output(path, json_output)
60
74
  File.write(path, json_output)
61
75
  puts "\nResults saved to #{path}"
62
76
  end
63
-
64
- def display_header(text)
65
- width = [TTY::Screen.width, 80].min
66
- puts TTY::Box.frame(text, width: width, padding: 1, align: :center, border: :thick)
67
- end
68
-
69
- def display_step_indicator(step_id, step_number)
70
- puts "\n Step #{step_number}: #{step_id}"
71
- puts " #{"─" * 40}"
72
- end
73
-
74
- def display_success(text)
75
- width = [TTY::Screen.width, 80].min
76
- puts TTY::Box.frame(text, width: width, padding: 1, align: :center, title: { top_left: " SUCCESS " })
77
- end
78
-
79
- def display_error(text)
80
- width = [TTY::Screen.width, 80].min
81
- puts TTY::Box.frame(text, width: width, padding: 1, align: :center, title: { top_left: " ERROR " })
82
- end
83
77
  end
84
78
  end
85
79
  end
@@ -3,11 +3,16 @@
3
3
  module FlowEngine
4
4
  module CLI
5
5
  module Commands
6
+ # Validates a flow definition file: start step exists, transitions point to
7
+ # known steps, and all steps are reachable from the start step.
6
8
  class ValidateFlow < Dry::CLI::Command
7
9
  desc "Validate a flow definition file"
8
10
 
9
11
  argument :flow_file, required: true, desc: "Path to flow definition (.rb file)"
10
12
 
13
+ # @param flow_file [String] path to the flow definition .rb file
14
+ # @param **_ [Hash] ignored options
15
+ # @return [void]
11
16
  def call(flow_file:, **)
12
17
  definition = FlowLoader.load(flow_file)
13
18
  errors = validate_definition(definition)
@@ -28,6 +33,8 @@ module FlowEngine
28
33
 
29
34
  private
30
35
 
36
+ # @param definition [FlowEngine::Definition]
37
+ # @return [void]
31
38
  def print_success(definition)
32
39
  puts "Flow definition is valid!"
33
40
  puts " Start step: #{definition.start_step_id}"
@@ -35,11 +42,15 @@ module FlowEngine
35
42
  puts " Steps: #{definition.step_ids.join(", ")}"
36
43
  end
37
44
 
45
+ # @param errors [Array<String>]
46
+ # @return [void]
38
47
  def print_errors(errors)
39
48
  warn "Flow definition has errors:"
40
49
  errors.each { |e| warn " - #{e}" }
41
50
  end
42
51
 
52
+ # @param definition [FlowEngine::Definition]
53
+ # @return [Array<String>] list of error messages
43
54
  def validate_definition(definition)
44
55
  errors = []
45
56
 
@@ -50,12 +61,18 @@ module FlowEngine
50
61
  errors
51
62
  end
52
63
 
64
+ # @param definition [FlowEngine::Definition]
65
+ # @param errors [Array<String>] mutated with new errors
66
+ # @return [void]
53
67
  def validate_start_step(definition, errors)
54
68
  return if definition.step_ids.include?(definition.start_step_id)
55
69
 
56
70
  errors << "Start step :#{definition.start_step_id} not found in steps"
57
71
  end
58
72
 
73
+ # @param definition [FlowEngine::Definition]
74
+ # @param errors [Array<String>] mutated with new errors
75
+ # @return [void]
59
76
  def validate_transition_targets(definition, errors)
60
77
  definition.step_ids.each do |step_id|
61
78
  step = definition.step(step_id)
@@ -67,6 +84,9 @@ module FlowEngine
67
84
  end
68
85
  end
69
86
 
87
+ # @param definition [FlowEngine::Definition]
88
+ # @param errors [Array<String>] mutated with new errors
89
+ # @return [void]
70
90
  def validate_reachability(definition, errors)
71
91
  reachable = find_reachable_steps(definition)
72
92
  orphans = definition.step_ids - reachable
@@ -76,6 +96,8 @@ module FlowEngine
76
96
  end
77
97
  end
78
98
 
99
+ # @param definition [FlowEngine::Definition]
100
+ # @return [Array<Symbol>] step ids reachable from start
79
101
  def find_reachable_steps(definition)
80
102
  visited = Set.new
81
103
  queue = [definition.start_step_id]
@@ -94,6 +116,10 @@ module FlowEngine
94
116
  visited.to_a
95
117
  end
96
118
 
119
+ # @param step [FlowEngine::Node]
120
+ # @param known_ids [Array<Symbol>]
121
+ # @param queue [Array] mutated with transition targets
122
+ # @return [void]
97
123
  def enqueue_transitions(step, known_ids, queue)
98
124
  step.transitions.each do |t|
99
125
  queue << t.target if known_ids.include?(t.target)
@@ -3,9 +3,12 @@
3
3
  module FlowEngine
4
4
  module CLI
5
5
  module Commands
6
+ # Prints flowengine-cli and flowengine gem versions to stdout.
6
7
  class Version < Dry::CLI::Command
7
8
  desc "Print version information"
8
9
 
10
+ # @param **_ [Hash] ignored options
11
+ # @return [void]
9
12
  def call(**)
10
13
  puts "flowengine-cli #{FlowEngine::CLI::VERSION}"
11
14
  puts "flowengine #{FlowEngine::VERSION}"
@@ -5,12 +5,16 @@ require_relative "commands/run"
5
5
  require_relative "commands/graph"
6
6
  require_relative "commands/validate_flow"
7
7
  require_relative "commands/version"
8
+ require_relative "ui_helper"
8
9
 
9
10
  module FlowEngine
10
11
  module CLI
12
+ # Dry::CLI registry for flowengine-cli subcommands: run, graph, validate, version.
11
13
  module Commands
12
14
  extend Dry::CLI::Registry
13
15
 
16
+ ::Dry::CLI::Command.include(UIHelper)
17
+
14
18
  register "run", Run
15
19
  register "graph", Graph
16
20
  register "validate", ValidateFlow
@@ -2,16 +2,27 @@
2
2
 
3
3
  module FlowEngine
4
4
  module CLI
5
+ # Loads a flow definition from a Ruby file by evaluating it in a top-level
6
+ # binding. Expects the file to define a flow via FlowEngine.define and
7
+ # return a FlowEngine::Definition.
5
8
  class FlowLoader
9
+ # Load a flow definition from a file.
10
+ # @param path [String] path to a .rb flow definition file
11
+ # @return [FlowEngine::Definition] the evaluated definition
12
+ # @raise [FlowEngine::CLI::Error] if file is missing, not .rb, or has syntax errors
6
13
  def self.load(path)
7
14
  new(path).load
8
15
  end
9
16
 
17
+ # @param path [String] path to the flow definition file (will be expanded)
10
18
  def initialize(path)
11
19
  @path = File.expand_path(path)
12
20
  validate_path!
13
21
  end
14
22
 
23
+ # Evaluates the file and returns the resulting definition.
24
+ # @return [FlowEngine::Definition]
25
+ # @raise [FlowEngine::CLI::Error] on syntax error
15
26
  def load
16
27
  content = File.read(@path)
17
28
  # Evaluate in a clean binding that has FlowEngine available
@@ -24,6 +35,7 @@ module FlowEngine
24
35
 
25
36
  private
26
37
 
38
+ # @raise [FlowEngine::CLI::Error] if path does not exist or does not end with .rb
27
39
  def validate_path!
28
40
  raise FlowEngine::CLI::Error, "File not found: #{@path}" unless File.exist?(@path)
29
41
  raise FlowEngine::CLI::Error, "Not a .rb file: #{@path}" unless @path.end_with?(".rb")
@@ -1,16 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "tty-prompt"
4
+ require_relative "ui_helper"
4
5
 
5
6
  module FlowEngine
6
7
  module CLI
8
+ # Renders flow steps as TTY prompts. Dispatches to a type-specific renderer
9
+ # (e.g. +render_multi_select+, +render_single_select+) or falls back to
10
+ # +render_text+ for unknown node types.
7
11
  class Renderer
12
+ # @return [TTY::Prompt] the TTY prompt instance used for input
8
13
  attr_reader :prompt
9
14
 
15
+ include ::FlowEngine::CLI::UIHelper
16
+
17
+ # @param prompt [TTY::Prompt] prompt instance (default: new TTY::Prompt)
10
18
  def initialize(prompt: TTY::Prompt.new)
11
19
  @prompt = prompt
12
20
  end
13
21
 
22
+ # Renders a single step node and returns the user's answer.
23
+ # @param node [FlowEngine::Node] the current step node from the flow definition
24
+ # @return [Object] answer value (String, Integer, Boolean, Array, Hash, or nil)
14
25
  def render(node)
15
26
  method_name = :"render_#{node.type}"
16
27
 
@@ -23,14 +34,20 @@ module FlowEngine
23
34
 
24
35
  private
25
36
 
37
+ # @param node [FlowEngine::Node] multi_select step
38
+ # @return [Array<String>]
26
39
  def render_multi_select(node)
27
40
  prompt.multi_select(node.question, node.options, min: 1)
28
41
  end
29
42
 
43
+ # @param node [FlowEngine::Node] single_select step
44
+ # @return [String]
30
45
  def render_single_select(node)
31
46
  prompt.select(node.question, node.options)
32
47
  end
33
48
 
49
+ # @param node [FlowEngine::Node] number_matrix step
50
+ # @return [Hash<String, Integer>]
34
51
  def render_number_matrix(node)
35
52
  puts "\n#{node.question}\n\n"
36
53
  result = {}
@@ -40,21 +57,44 @@ module FlowEngine
40
57
  result
41
58
  end
42
59
 
60
+ # @param node [FlowEngine::Node] text step
61
+ # @return [String, nil]
43
62
  def render_text(node)
44
63
  prompt.ask(node.question)
45
64
  end
46
65
 
66
+ # @param node [FlowEngine::Node] number step
67
+ # @return [Integer, nil]
47
68
  def render_number(node)
48
69
  prompt.ask(node.question, convert: :int)
49
70
  end
50
71
 
72
+ # @param node [FlowEngine::Node] boolean step
73
+ # @return [Boolean]
51
74
  def render_boolean(node) # rubocop:disable Naming/PredicateMethod
52
75
  prompt.yes?(node.question)
53
76
  end
54
77
 
78
+ # @param node [FlowEngine::Node] display step (informational, no answer)
79
+ # @return [nil]
55
80
  def render_display(node)
56
81
  puts "\n#{node.question}\n"
57
82
  prompt.keypress("Press any key to continue...")
83
+ sep(:green, "━")
84
+ nil
85
+ end
86
+
87
+ # Renders a header node with a title and a separator.
88
+ # @param node [FlowEngine::Node] header step (optional decorations)
89
+ # @return [nil]
90
+ def render_header(node)
91
+ title = node.respond_to?(:decorations) ? node.decorations : nil
92
+ opts = {}
93
+ opts[:title] = { style: { top_left: title } } if title
94
+ puts box(node.question, bg: :blue, fg: :white, **opts)
95
+ puts node.question
96
+ prompt.keypress("Press any key to continue...")
97
+ sep(:green, "━")
58
98
  nil
59
99
  end
60
100
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
4
+
5
+ require "forwardable"
6
+ require "tty-box"
7
+ require "tty-screen"
8
+ require "pastel"
9
+
10
+ module FlowEngine
11
+ module CLI
12
+ # Mixin that provides TTY::Box, Pastel, and screen helpers to CLI commands
13
+ # and the Renderer. Defines +box+, +sep+, +next_step+, +frame+, +info+,
14
+ # +success+, +error+, +warning+, and +width+ on the including class.
15
+ module UIHelper
16
+ class << self
17
+ # @return [Pastel] shared Pastel instance for colored output
18
+ def pastel
19
+ @pastel ||= Pastel.new
20
+ end
21
+
22
+ def included(base)
23
+ base.extend(Forwardable)
24
+ # Forwardable needs an accessor (method returning the target).
25
+ # :TTY::Box / :TTY::Screen are not valid method names on the instance.
26
+ base.define_method(:tty_box) { ::TTY::Box }
27
+ base.define_method(:tty_screen) { ::TTY::Screen }
28
+ base.define_method(:pastel) { ::FlowEngine::CLI::UIHelper.pastel }
29
+
30
+ # TTY::Box uses :warn, not :warning
31
+ %i[frame info success error].each do |method|
32
+ base.define_method(method) { |*args, **kwargs| puts ::TTY::Box.send(method, *args, **kwargs) }
33
+ end
34
+ base.define_method(:warning) { |*args, **kwargs| puts ::TTY::Box.send(:warn, *args, **kwargs) }
35
+
36
+ base.def_delegators :tty_screen, :width
37
+
38
+ base.class_eval do
39
+ # Draw a bordered box with optional title.
40
+ # @param text [String] content to display
41
+ # @param title [String, nil] optional top-left title
42
+ # @param bg [Symbol] background color (e.g. :green, :blue)
43
+ # @param fg [Symbol] foreground color (e.g. :white)
44
+ # @return [void]
45
+ def box(text, title: nil, bg: :green, fg: :white) # rubocop:disable Naming/MethodParameterName
46
+ width = [width(), 80].min
47
+ args = {
48
+ width: width,
49
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
50
+ align: :center,
51
+ style: { fg: fg,
52
+ bg: bg,
53
+ border: { type: :thin, fg: fg, bg: bg } }
54
+ }
55
+ args[:title] = { top_left: title } if title
56
+ frame(text, **args)
57
+ end
58
+
59
+ # Print step progress line and separator.
60
+ # @param step_id [Symbol, String] current step identifier
61
+ # @param step_number [Integer] 1-based step index
62
+ # @return [void]
63
+ def next_step(step_id, step_number)
64
+ puts pastel.yellow("Step #{step_number}: #{step_id}")
65
+ sep(:yellow, "━")
66
+ end
67
+
68
+ # Print a horizontal separator line in the given color.
69
+ # @param color [Symbol] Pastel color (e.g. :yellow, :green)
70
+ # @param char [String] character to repeat (default "▪")
71
+ # @return [void]
72
+ def sep(color = :yellow, char = "▪")
73
+ puts pastel.send(color, (char * 80).to_s)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
@@ -2,6 +2,7 @@
2
2
 
3
3
  module FlowEngine
4
4
  module CLI
5
- VERSION = "0.1.0"
5
+ # Semantic version of the flowengine-cli gem.
6
+ VERSION = "0.1.2"
6
7
  end
7
8
  end
@@ -7,7 +7,10 @@ require_relative "cli/renderer"
7
7
  require_relative "cli/commands"
8
8
 
9
9
  module FlowEngine
10
+ # Terminal UI adapter for flowengine: runs flows interactively via Dry::CLI
11
+ # and TTY components, and supports graph export and flow validation.
10
12
  module CLI
13
+ # Raised when flowengine-cli encounters a load, validation, or runtime error.
11
14
  class Error < StandardError; end
12
15
  end
13
16
  end