language-operator 0.1.31 → 0.1.35

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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +7 -8
  3. data/CHANGELOG.md +14 -0
  4. data/CI_STATUS.md +56 -0
  5. data/Gemfile.lock +2 -2
  6. data/Makefile +22 -6
  7. data/lib/language_operator/agent/base.rb +10 -6
  8. data/lib/language_operator/agent/executor.rb +19 -97
  9. data/lib/language_operator/agent/safety/ast_validator.rb +62 -43
  10. data/lib/language_operator/agent/safety/safe_executor.rb +27 -2
  11. data/lib/language_operator/agent/scheduler.rb +60 -0
  12. data/lib/language_operator/agent/task_executor.rb +548 -0
  13. data/lib/language_operator/agent.rb +90 -27
  14. data/lib/language_operator/cli/base_command.rb +117 -0
  15. data/lib/language_operator/cli/commands/agent.rb +339 -407
  16. data/lib/language_operator/cli/commands/cluster.rb +274 -290
  17. data/lib/language_operator/cli/commands/install.rb +110 -119
  18. data/lib/language_operator/cli/commands/model.rb +284 -184
  19. data/lib/language_operator/cli/commands/persona.rb +218 -284
  20. data/lib/language_operator/cli/commands/quickstart.rb +4 -5
  21. data/lib/language_operator/cli/commands/status.rb +31 -35
  22. data/lib/language_operator/cli/commands/system.rb +221 -233
  23. data/lib/language_operator/cli/commands/tool.rb +356 -422
  24. data/lib/language_operator/cli/commands/use.rb +19 -22
  25. data/lib/language_operator/cli/helpers/resource_dependency_checker.rb +0 -18
  26. data/lib/language_operator/cli/wizards/quickstart_wizard.rb +0 -1
  27. data/lib/language_operator/client/config.rb +20 -21
  28. data/lib/language_operator/config.rb +115 -3
  29. data/lib/language_operator/constants.rb +54 -0
  30. data/lib/language_operator/dsl/agent_context.rb +7 -7
  31. data/lib/language_operator/dsl/agent_definition.rb +111 -26
  32. data/lib/language_operator/dsl/config.rb +30 -66
  33. data/lib/language_operator/dsl/main_definition.rb +114 -0
  34. data/lib/language_operator/dsl/schema.rb +84 -43
  35. data/lib/language_operator/dsl/task_definition.rb +315 -0
  36. data/lib/language_operator/dsl.rb +0 -1
  37. data/lib/language_operator/instrumentation/task_tracer.rb +285 -0
  38. data/lib/language_operator/logger.rb +4 -4
  39. data/lib/language_operator/synthesis_test_harness.rb +324 -0
  40. data/lib/language_operator/templates/examples/agent_synthesis.tmpl +26 -8
  41. data/lib/language_operator/templates/schema/CHANGELOG.md +26 -0
  42. data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +1 -1
  43. data/lib/language_operator/templates/schema/agent_dsl_schema.json +84 -42
  44. data/lib/language_operator/type_coercion.rb +250 -0
  45. data/lib/language_operator/ux/base.rb +81 -0
  46. data/lib/language_operator/ux/concerns/README.md +155 -0
  47. data/lib/language_operator/ux/concerns/headings.rb +90 -0
  48. data/lib/language_operator/ux/concerns/input_validation.rb +146 -0
  49. data/lib/language_operator/ux/concerns/provider_helpers.rb +167 -0
  50. data/lib/language_operator/ux/create_agent.rb +252 -0
  51. data/lib/language_operator/ux/create_model.rb +267 -0
  52. data/lib/language_operator/ux/quickstart.rb +594 -0
  53. data/lib/language_operator/version.rb +1 -1
  54. data/lib/language_operator.rb +2 -0
  55. data/requirements/ARCHITECTURE.md +1 -0
  56. data/requirements/SCRATCH.md +153 -0
  57. data/requirements/dsl.md +0 -0
  58. data/requirements/features +1 -0
  59. data/requirements/personas +1 -0
  60. data/requirements/proposals +1 -0
  61. data/requirements/tasks/iterate.md +14 -15
  62. data/requirements/tasks/optimize.md +13 -4
  63. data/synth/001/Makefile +90 -0
  64. data/synth/001/agent.rb +26 -0
  65. data/synth/001/agent.yaml +7 -0
  66. data/synth/001/output.log +44 -0
  67. data/synth/Makefile +39 -0
  68. data/synth/README.md +342 -0
  69. metadata +37 -10
  70. data/lib/language_operator/dsl/workflow_definition.rb +0 -259
  71. data/test_agent_dsl.rb +0 -108
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LanguageOperator
4
+ # Type coercion system for task inputs and outputs
5
+ #
6
+ # Provides smart type coercion with automatic conversion for common cases
7
+ # and clear error messages when coercion is not possible. This enables
8
+ # flexible type handling while maintaining type safety.
9
+ #
10
+ # Supported types:
11
+ # - integer: Coerces String, Integer, Float to Integer
12
+ # - number: Coerces String, Integer, Float to Float
13
+ # - string: Coerces any value to String via to_s
14
+ # - boolean: Coerces String, Boolean to Boolean (explicit values only)
15
+ # - array: Strict validation (no coercion)
16
+ # - hash: Strict validation (no coercion)
17
+ # - any: No coercion, passes through any value
18
+ #
19
+ # @example Integer coercion
20
+ # TypeCoercion.coerce("123", "integer") # => 123
21
+ # TypeCoercion.coerce(123, "integer") # => 123
22
+ # TypeCoercion.coerce("abc", "integer") # raises ArgumentError
23
+ #
24
+ # @example Boolean coercion
25
+ # TypeCoercion.coerce("true", "boolean") # => true
26
+ # TypeCoercion.coerce("1", "boolean") # => true
27
+ # TypeCoercion.coerce("false", "boolean") # => false
28
+ # TypeCoercion.coerce("maybe", "boolean") # raises ArgumentError
29
+ #
30
+ # @example String coercion (never fails)
31
+ # TypeCoercion.coerce(:symbol, "string") # => "symbol"
32
+ # TypeCoercion.coerce(123, "string") # => "123"
33
+ #
34
+ # @example Strict validation
35
+ # TypeCoercion.coerce([1, 2], "array") # => [1, 2]
36
+ # TypeCoercion.coerce({a: 1}, "array") # raises ArgumentError
37
+ module TypeCoercion
38
+ # Coerce a value to the specified type
39
+ #
40
+ # @param value [Object] Value to coerce
41
+ # @param type [String] Target type (see COERCION_RULES for valid types)
42
+ # @return [Object] Coerced value
43
+ # @raise [ArgumentError] If coercion fails or type is unknown
44
+ #
45
+ # @example
46
+ # TypeCoercion.coerce("345", "integer") # => 345
47
+ # TypeCoercion.coerce("true", "boolean") # => true
48
+ def self.coerce(value, type)
49
+ case type
50
+ when 'integer'
51
+ coerce_integer(value)
52
+ when 'number'
53
+ coerce_number(value)
54
+ when 'string'
55
+ coerce_string(value)
56
+ when 'boolean'
57
+ coerce_boolean(value)
58
+ when 'array'
59
+ validate_array(value)
60
+ when 'hash'
61
+ validate_hash(value)
62
+ when 'any'
63
+ value
64
+ else
65
+ raise ArgumentError, "Unknown type: #{type}"
66
+ end
67
+ end
68
+
69
+ # Coerce value to Integer
70
+ #
71
+ # Accepts: String, Integer, Float
72
+ # Coercion: Uses Ruby's Integer() method
73
+ # Errors: Cannot parse as integer
74
+ #
75
+ # @param value [Object] Value to coerce
76
+ # @return [Integer] Coerced integer
77
+ # @raise [ArgumentError] If coercion fails
78
+ #
79
+ # @example
80
+ # coerce_integer("123") # => 123
81
+ # coerce_integer(123) # => 123
82
+ # coerce_integer(123.0) # => 123
83
+ # coerce_integer("abc") # raises ArgumentError
84
+ def self.coerce_integer(value)
85
+ return value if value.is_a?(Integer)
86
+
87
+ Integer(value)
88
+ rescue ArgumentError, TypeError => e
89
+ raise ArgumentError, "Cannot coerce #{value.inspect} to integer: #{e.message}"
90
+ end
91
+
92
+ # Coerce value to Float (number)
93
+ #
94
+ # Accepts: String, Integer, Float
95
+ # Coercion: Uses Ruby's Float() method
96
+ # Errors: Cannot parse as number
97
+ #
98
+ # @param value [Object] Value to coerce
99
+ # @return [Float] Coerced number
100
+ # @raise [ArgumentError] If coercion fails
101
+ #
102
+ # @example
103
+ # coerce_number("3.14") # => 3.14
104
+ # coerce_number(3) # => 3.0
105
+ # coerce_number(3.14) # => 3.14
106
+ # coerce_number("not a num") # raises ArgumentError
107
+ def self.coerce_number(value)
108
+ return value if value.is_a?(Numeric)
109
+
110
+ Float(value)
111
+ rescue ArgumentError, TypeError => e
112
+ raise ArgumentError, "Cannot coerce #{value.inspect} to number: #{e.message}"
113
+ end
114
+
115
+ # Coerce value to String
116
+ #
117
+ # Accepts: Any object with to_s method (all Ruby objects)
118
+ # Coercion: Uses to_s method
119
+ # Errors: Never (everything has to_s)
120
+ #
121
+ # @param value [Object] Value to coerce
122
+ # @return [String] Coerced string
123
+ #
124
+ # @example
125
+ # coerce_string(:symbol) # => "symbol"
126
+ # coerce_string(123) # => "123"
127
+ # coerce_string(nil) # => ""
128
+ def self.coerce_string(value)
129
+ value.to_s
130
+ end
131
+
132
+ # Coerce value to Boolean
133
+ #
134
+ # Accepts: Boolean, String (explicit values only)
135
+ # Coercion: Case-insensitive string matching
136
+ # Truthy: "true", "1", "yes", "t", "y"
137
+ # Falsy: "false", "0", "no", "f", "n"
138
+ # Errors: Ambiguous values (e.g., "maybe", "unknown")
139
+ #
140
+ # @param value [Object] Value to coerce
141
+ # @return [Boolean] Coerced boolean
142
+ # @raise [ArgumentError] If coercion is ambiguous
143
+ #
144
+ # @example
145
+ # coerce_boolean(true) # => true
146
+ # coerce_boolean("true") # => true
147
+ # coerce_boolean("1") # => true
148
+ # coerce_boolean("yes") # => true
149
+ # coerce_boolean(false) # => false
150
+ # coerce_boolean("false") # => false
151
+ # coerce_boolean("0") # => false
152
+ # coerce_boolean("no") # => false
153
+ # coerce_boolean("maybe") # raises ArgumentError
154
+ def self.coerce_boolean(value)
155
+ return value if value.is_a?(TrueClass) || value.is_a?(FalseClass)
156
+
157
+ # Only allow string values for coercion (not integers or other types)
158
+ raise ArgumentError, "Cannot coerce #{value.inspect} to boolean" unless value.is_a?(String)
159
+
160
+ str = value.strip.downcase
161
+ return true if %w[true 1 yes t y].include?(str)
162
+ return false if %w[false 0 no f n].include?(str)
163
+
164
+ raise ArgumentError, "Cannot coerce #{value.inspect} to boolean"
165
+ end
166
+
167
+ # Validate value is an Array (no coercion)
168
+ #
169
+ # Accepts: Array
170
+ # Coercion: None (strict validation)
171
+ # Errors: Not an array
172
+ #
173
+ # @param value [Object] Value to validate
174
+ # @return [Array] Validated array
175
+ # @raise [ArgumentError] If not an array
176
+ #
177
+ # @example
178
+ # validate_array([1, 2, 3]) # => [1, 2, 3]
179
+ # validate_array({a: 1}) # raises ArgumentError
180
+ def self.validate_array(value)
181
+ raise ArgumentError, "Expected array, got #{value.class}" unless value.is_a?(Array)
182
+
183
+ value
184
+ end
185
+
186
+ # Validate value is a Hash (no coercion)
187
+ #
188
+ # Accepts: Hash
189
+ # Coercion: None (strict validation)
190
+ # Errors: Not a hash
191
+ #
192
+ # @param value [Object] Value to validate
193
+ # @return [Hash] Validated hash
194
+ # @raise [ArgumentError] If not a hash
195
+ #
196
+ # @example
197
+ # validate_hash({a: 1, b: 2}) # => {a: 1, b: 2}
198
+ # validate_hash([1, 2]) # raises ArgumentError
199
+ def self.validate_hash(value)
200
+ raise ArgumentError, "Expected hash, got #{value.class}" unless value.is_a?(Hash)
201
+
202
+ value
203
+ end
204
+
205
+ # Coercion rules table
206
+ #
207
+ # @return [Hash] Mapping of types to their coercion behavior
208
+ # rubocop:disable Metrics/MethodLength
209
+ def self.coercion_rules
210
+ {
211
+ 'integer' => {
212
+ accepts: 'String, Integer, Float',
213
+ method: 'Integer(value)',
214
+ errors: 'Cannot parse as integer'
215
+ },
216
+ 'number' => {
217
+ accepts: 'String, Integer, Float',
218
+ method: 'Float(value)',
219
+ errors: 'Cannot parse as number'
220
+ },
221
+ 'string' => {
222
+ accepts: 'Any object',
223
+ method: 'value.to_s',
224
+ errors: 'Never (everything has to_s)'
225
+ },
226
+ 'boolean' => {
227
+ accepts: 'Boolean, String (explicit values)',
228
+ method: 'Pattern matching (true/1/yes/t/y or false/0/no/f/n)',
229
+ errors: 'Ambiguous values'
230
+ },
231
+ 'array' => {
232
+ accepts: 'Array only',
233
+ method: 'No coercion (strict)',
234
+ errors: 'Not an array'
235
+ },
236
+ 'hash' => {
237
+ accepts: 'Hash only',
238
+ method: 'No coercion (strict)',
239
+ errors: 'Not a hash'
240
+ },
241
+ 'any' => {
242
+ accepts: 'Any value',
243
+ method: 'No coercion (pass-through)',
244
+ errors: 'Never'
245
+ }
246
+ }
247
+ end
248
+ # rubocop:enable Metrics/MethodLength
249
+ end
250
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-prompt'
4
+ require 'pastel'
5
+ require_relative '../cli/formatters/progress_formatter'
6
+
7
+ module LanguageOperator
8
+ module Ux
9
+ # Base class for interactive user experience flows
10
+ #
11
+ # Provides common infrastructure for TTY-based interactive wizards
12
+ # including cluster context validation and standard UI helpers.
13
+ #
14
+ # @example Creating a new UX flow
15
+ # class CreateModel < Base
16
+ # def execute
17
+ # show_welcome
18
+ # # ... flow logic
19
+ # end
20
+ # end
21
+ #
22
+ # @example Using a UX flow
23
+ # Ux::CreateModel.execute(ctx)
24
+ #
25
+ class Base
26
+ attr_reader :prompt, :pastel, :ctx
27
+
28
+ # Initialize the UX flow
29
+ #
30
+ # @param ctx [Object, nil] Cluster context (required unless overridden)
31
+ def initialize(ctx = nil)
32
+ @prompt = TTY::Prompt.new
33
+ @pastel = Pastel.new
34
+ @ctx = ctx
35
+
36
+ validate_cluster_context! if requires_cluster?
37
+ end
38
+
39
+ # Execute the UX flow
40
+ #
41
+ # Subclasses must override this method to implement their flow logic.
42
+ #
43
+ # @raise [NotImplementedError] if not overridden by subclass
44
+ def execute
45
+ raise NotImplementedError, "#{self.class.name} must implement #execute"
46
+ end
47
+
48
+ # Execute the UX flow as a class method
49
+ #
50
+ # @param ctx [Object, nil] Cluster context
51
+ # @return [Object] Result of execute method
52
+ def self.execute(ctx = nil)
53
+ new(ctx).execute
54
+ end
55
+
56
+ private
57
+
58
+ # Whether this flow requires a cluster context
59
+ #
60
+ # Subclasses can override to disable cluster requirement (e.g., Quickstart).
61
+ #
62
+ # @return [Boolean] true if cluster is required
63
+ def requires_cluster?
64
+ true
65
+ end
66
+
67
+ # Validate that a cluster context is available
68
+ #
69
+ # Exits with error if cluster is required but not provided.
70
+ def validate_cluster_context!
71
+ return unless requires_cluster?
72
+ return if ctx
73
+
74
+ CLI::Formatters::ProgressFormatter.error(
75
+ 'No cluster selected. Run "aictl cluster add" first.'
76
+ )
77
+ exit 1
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,155 @@
1
+ # Ux::Concerns
2
+
3
+ Reusable mixins for interactive UX flows.
4
+
5
+ ## Available Concerns
6
+
7
+ ### Headings
8
+
9
+ Provides consistent formatting for headings, step indicators, and banners.
10
+
11
+ ```ruby
12
+ class MyFlow < Ux::Base
13
+ include Concerns::Headings
14
+
15
+ def execute
16
+ heading('Welcome!', emoji: '🎉')
17
+ step_heading(1, 3, 'First Step')
18
+ subheading('Configuration')
19
+ separator
20
+ end
21
+ end
22
+ ```
23
+
24
+ **Methods:**
25
+ - `heading(text, emoji: nil, width: 50)` - Display prominent heading with border
26
+ - `step_heading(current, total, title, width: 50)` - Display step indicator
27
+ - `subheading(text)` - Display simple subheading
28
+ - `separator(width: 50, char: '─')` - Display separator line
29
+ - `section(title, description: nil)` - Display section header with description
30
+
31
+ ### ProviderHelpers
32
+
33
+ Common operations for LLM provider integration.
34
+
35
+ ```ruby
36
+ class MyFlow < Ux::Base
37
+ include Concerns::ProviderHelpers
38
+
39
+ def execute
40
+ result = test_provider_connection(:anthropic, api_key: 'sk-...')
41
+ models = fetch_provider_models(:openai, api_key: 'sk-...')
42
+ info = provider_info(:anthropic)
43
+ end
44
+ end
45
+ ```
46
+
47
+ **Methods:**
48
+ - `test_provider_connection(provider, api_key:, endpoint:)` - Test connection to provider
49
+ - `fetch_provider_models(provider, api_key:, endpoint:)` - Fetch available models
50
+ - `provider_info(provider)` - Get provider display info and documentation URLs
51
+
52
+ **Supported Providers:**
53
+ - `:anthropic` - Anthropic (Claude)
54
+ - `:openai` - OpenAI (GPT)
55
+ - `:openai_compatible` - OpenAI-compatible endpoints (Ollama, vLLM, LM Studio, etc)
56
+
57
+ ### InputValidation
58
+
59
+ Common input validation and prompting helpers.
60
+
61
+ ```ruby
62
+ class MyFlow < Ux::Base
63
+ include Concerns::InputValidation
64
+
65
+ def execute
66
+ url = ask_url('Enter endpoint URL:')
67
+ name = ask_k8s_name('Resource name:', default: 'my-resource')
68
+ email = ask_email('Your email:')
69
+ api_key = ask_secret('API key:')
70
+ port = ask_port('Port:', default: 8080)
71
+ confirmed = ask_yes_no('Continue?', default: true)
72
+ end
73
+ end
74
+ ```
75
+
76
+ **Methods:**
77
+ - `ask_url(question, default:, required:)` - Prompt for URL with validation
78
+ - `ask_k8s_name(question, default:)` - Prompt for Kubernetes resource name
79
+ - `ask_email(question, default:)` - Prompt for email address
80
+ - `ask_secret(question, required:)` - Prompt for masked input (API keys, passwords)
81
+ - `ask_port(question, default:)` - Prompt for port number (1-65535)
82
+ - `ask_yes_no(question, default:)` - Prompt for yes/no confirmation
83
+ - `ask_select(question, choices, per_page:)` - Prompt for selection from list
84
+ - `validate_k8s_name(name)` - Validate and normalize Kubernetes name
85
+ - `validate_url(url)` - Validate URL format
86
+
87
+ ## Usage Guidelines
88
+
89
+ ### When to Use Concerns
90
+
91
+ ✅ **DO use concerns for:**
92
+ - Common formatting patterns used across multiple flows
93
+ - Repeated validation logic
94
+ - Shared provider/API operations
95
+ - Reusable UI components
96
+
97
+ ❌ **DON'T use concerns for:**
98
+ - Flow-specific business logic
99
+ - One-off operations
100
+ - Complex state management
101
+
102
+ ### Naming Convention
103
+
104
+ Concerns should be:
105
+ - Named as adjectives or capabilities (e.g., `Headings`, `ProviderHelpers`)
106
+ - Focused on a single responsibility
107
+ - Well-documented with examples
108
+
109
+ ### Testing
110
+
111
+ Each concern should have corresponding specs in `spec/language_operator/ux/concerns/`.
112
+
113
+ ## Creating New Concerns
114
+
115
+ 1. Create file in `lib/language_operator/ux/concerns/my_concern.rb`
116
+ 2. Define module under `LanguageOperator::Ux::Concerns`
117
+ 3. Add YARD documentation with examples
118
+ 4. Include in flows via `include Concerns::MyConcern`
119
+ 5. Add tests in `spec/language_operator/ux/concerns/my_concern_spec.rb`
120
+ 6. Update this README
121
+
122
+ ## Example: Creating a New Concern
123
+
124
+ ```ruby
125
+ # lib/language_operator/ux/concerns/kubernetes_helpers.rb
126
+ module LanguageOperator
127
+ module Ux
128
+ module Concerns
129
+ # Mixin for Kubernetes resource operations
130
+ module KubernetesHelpers
131
+ def resource_exists?(type, name)
132
+ ctx.client.get_resource(type, name, ctx.namespace)
133
+ true
134
+ rescue K8s::Error::NotFound
135
+ false
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ ```
142
+
143
+ Then use it:
144
+
145
+ ```ruby
146
+ class CreateAgent < Base
147
+ include Concerns::KubernetesHelpers
148
+
149
+ def execute
150
+ if resource_exists?('LanguageAgent', 'my-agent')
151
+ puts "Agent already exists!"
152
+ end
153
+ end
154
+ end
155
+ ```
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LanguageOperator
4
+ module Ux
5
+ module Concerns
6
+ # Mixin for consistent heading and banner formatting in UX flows
7
+ #
8
+ # Provides helpers for creating section headers, step indicators,
9
+ # welcome banners, and separator lines.
10
+ #
11
+ # @example
12
+ # class MyFlow < Base
13
+ # include Concerns::Headings
14
+ #
15
+ # def execute
16
+ # heading('Welcome to My Flow', emoji: '🎉')
17
+ # step_heading(1, 5, 'First Step')
18
+ # # ... flow logic
19
+ # end
20
+ # end
21
+ module Headings
22
+ def title(text)
23
+ puts
24
+ puts pastel.bold.green("LANGUAGE OPERATOR v#{LanguageOperator::VERSION}")
25
+ puts pastel.dim("↪ #{text}")
26
+ end
27
+
28
+ # Display a prominent heading with optional emoji
29
+ #
30
+ # @param text [String] The heading text
31
+ # @param emoji [String, nil] Optional emoji to display
32
+ # @param width [Integer] Width of the banner (default: 50)
33
+ def heading(text, emoji: nil, width: 50)
34
+ border = "╭#{'─' * (width - 2)}╮"
35
+ bottom = "╰#{'─' * (width - 2)}╯"
36
+
37
+ display_text = emoji ? "#{text} #{emoji}" : text
38
+ padding = width - display_text.length - 4
39
+
40
+ puts
41
+ puts pastel.cyan(border)
42
+ puts "#{pastel.cyan('│')} #{display_text}#{' ' * padding}#{pastel.cyan('│')}"
43
+ puts pastel.cyan(bottom)
44
+ puts
45
+ end
46
+
47
+ # Display a step heading with step number
48
+ #
49
+ # @param current [Integer] Current step number
50
+ # @param total [Integer] Total number of steps
51
+ # @param title [String] Step title
52
+ # @param width [Integer] Width of the separator line (default: 50)
53
+ def step_heading(current, total, title, width: 50)
54
+ puts
55
+ puts '─' * width
56
+ puts pastel.cyan("Step #{current}/#{total}: #{title}")
57
+ puts '─' * width
58
+ puts
59
+ end
60
+
61
+ # Display a simple subheading
62
+ #
63
+ # @param text [String] The subheading text
64
+ def subheading(text)
65
+ puts
66
+ puts pastel.bold(text)
67
+ end
68
+
69
+ # Display a separator line
70
+ #
71
+ # @param width [Integer] Width of the line (default: 50)
72
+ # @param char [String] Character to use for the line (default: '─')
73
+ def separator(width: 50, char: '─')
74
+ puts char * width
75
+ end
76
+
77
+ # Display a section header with description
78
+ #
79
+ # @param title [String] Section title
80
+ # @param description [String, nil] Optional description
81
+ def section(title, description: nil)
82
+ puts
83
+ puts pastel.cyan.bold(title)
84
+ puts pastel.dim(description) if description
85
+ puts
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end