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,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LanguageOperator
4
+ module Ux
5
+ module Concerns
6
+ # Mixin for common input validation and prompting patterns
7
+ #
8
+ # Provides helpers for common validation scenarios like URLs, emails,
9
+ # Kubernetes resource names, and other frequently validated inputs.
10
+ #
11
+ # @example
12
+ # class MyFlow < Base
13
+ # include Concerns::InputValidation
14
+ #
15
+ # def execute
16
+ # url = ask_url('Enter endpoint URL:')
17
+ # name = ask_k8s_name('Resource name:')
18
+ # end
19
+ # end
20
+ module InputValidation
21
+ # Ask for a URL with validation
22
+ #
23
+ # @param question [String] The prompt question
24
+ # @param default [String, nil] Default value
25
+ # @param required [Boolean] Whether input is required
26
+ # @return [String, nil] The validated URL or nil if cancelled
27
+ def ask_url(question, default: nil, required: true)
28
+ prompt.ask(question, default: default) do |q|
29
+ q.required required
30
+ q.validate(%r{^https?://})
31
+ q.messages[:valid?] = 'Must be a valid HTTP(S) URL'
32
+ end
33
+ rescue TTY::Reader::InputInterrupt
34
+ nil
35
+ end
36
+
37
+ # Ask for a Kubernetes-compatible resource name
38
+ #
39
+ # @param question [String] The prompt question
40
+ # @param default [String, nil] Default value
41
+ # @return [String, nil] The validated name or nil if cancelled
42
+ def ask_k8s_name(question, default: nil)
43
+ prompt.ask(question, default: default) do |q|
44
+ q.required true
45
+ q.validate(/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/, 'Must be lowercase alphanumeric with hyphens')
46
+ q.modify :strip, :down
47
+ end
48
+ rescue TTY::Reader::InputInterrupt
49
+ nil
50
+ end
51
+
52
+ # Ask for an email address with validation
53
+ #
54
+ # @param question [String] The prompt question
55
+ # @param default [String, nil] Default value
56
+ # @return [String, nil] The validated email or nil if cancelled
57
+ def ask_email(question, default: nil)
58
+ prompt.ask(question, default: default) do |q|
59
+ q.required true
60
+ q.validate(/\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i)
61
+ q.messages[:valid?] = 'Please enter a valid email address'
62
+ end
63
+ rescue TTY::Reader::InputInterrupt
64
+ nil
65
+ end
66
+
67
+ # Ask for a masked input (like API keys or passwords)
68
+ #
69
+ # @param question [String] The prompt question
70
+ # @param required [Boolean] Whether input is required
71
+ # @return [String, nil] The input value or nil if cancelled
72
+ def ask_secret(question, required: true)
73
+ prompt.mask(question) do |q|
74
+ q.required required
75
+ end
76
+ rescue TTY::Reader::InputInterrupt
77
+ nil
78
+ end
79
+
80
+ # Ask for a port number with validation
81
+ #
82
+ # @param question [String] The prompt question
83
+ # @param default [Integer, nil] Default value
84
+ # @return [Integer, nil] The validated port or nil if cancelled
85
+ def ask_port(question, default: nil)
86
+ prompt.ask(question, default: default, convert: :int) do |q|
87
+ q.required true
88
+ q.validate(->(v) { v.to_i.between?(1, 65_535) })
89
+ q.messages[:valid?] = 'Must be a valid port number (1-65535)'
90
+ end
91
+ rescue TTY::Reader::InputInterrupt
92
+ nil
93
+ end
94
+
95
+ # Ask a yes/no question
96
+ #
97
+ # @param question [String] The prompt question
98
+ # @param default [Boolean] Default value
99
+ # @return [Boolean, nil] The response or nil if cancelled
100
+ def ask_yes_no(question, default: false)
101
+ prompt.yes?(question, default: default)
102
+ rescue TTY::Reader::InputInterrupt
103
+ nil
104
+ end
105
+
106
+ # Ask for selection from a list
107
+ #
108
+ # @param question [String] The prompt question
109
+ # @param choices [Array] Array of choices
110
+ # @param per_page [Integer] Items per page for pagination
111
+ # @return [Object, nil] The selected choice or nil if cancelled
112
+ def ask_select(question, choices, per_page: 10)
113
+ prompt.select(question, choices, per_page: per_page)
114
+ rescue TTY::Reader::InputInterrupt
115
+ nil
116
+ end
117
+
118
+ # Validate and coerce a Kubernetes resource name
119
+ #
120
+ # @param name [String] The name to validate
121
+ # @return [String] The validated and normalized name
122
+ # @raise [ArgumentError] If name is invalid
123
+ def validate_k8s_name(name)
124
+ normalized = name.to_s.downcase.strip
125
+ raise ArgumentError, "Invalid Kubernetes name: #{name}" unless normalized.match?(/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/)
126
+
127
+ normalized
128
+ end
129
+
130
+ # Validate a URL
131
+ #
132
+ # @param url [String] The URL to validate
133
+ # @return [String] The validated URL
134
+ # @raise [ArgumentError] If URL is invalid
135
+ def validate_url(url)
136
+ uri = URI.parse(url)
137
+ raise ArgumentError, "Invalid URL: #{url}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
138
+
139
+ url
140
+ rescue URI::InvalidURIError => e
141
+ raise ArgumentError, "Invalid URL: #{e.message}"
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module LanguageOperator
8
+ module Ux
9
+ module Concerns
10
+ # Mixin for common LLM provider operations
11
+ #
12
+ # Provides helpers for testing provider connections, fetching available models,
13
+ # and handling provider-specific configuration.
14
+ #
15
+ # @example
16
+ # class MyFlow < Base
17
+ # include Concerns::ProviderHelpers
18
+ #
19
+ # def setup
20
+ # result = test_provider_connection(:anthropic, api_key: 'sk-...')
21
+ # models = fetch_provider_models(:openai, api_key: 'sk-...')
22
+ # end
23
+ # end
24
+ module ProviderHelpers
25
+ # Test connection to an LLM provider
26
+ #
27
+ # @param provider [Symbol] Provider type (:anthropic, :openai, :openai_compatible)
28
+ # @param api_key [String, nil] API key for authentication
29
+ # @param endpoint [String, nil] Custom endpoint URL (for openai_compatible)
30
+ # @return [Hash] Result with :success and optional :error keys
31
+ def test_provider_connection(provider, api_key: nil, endpoint: nil)
32
+ require 'ruby_llm'
33
+
34
+ case provider
35
+ when :anthropic
36
+ test_anthropic(api_key)
37
+ when :openai
38
+ test_openai(api_key)
39
+ when :openai_compatible
40
+ test_openai_compatible(endpoint, api_key)
41
+ else
42
+ { success: false, error: "Unknown provider: #{provider}" }
43
+ end
44
+ end
45
+
46
+ # Fetch available models from a provider
47
+ #
48
+ # @param provider [Symbol] Provider type
49
+ # @param api_key [String, nil] API key for authentication
50
+ # @param endpoint [String, nil] Custom endpoint URL
51
+ # @return [Array<String>, nil] List of model IDs or nil if unavailable
52
+ def fetch_provider_models(provider, api_key: nil, endpoint: nil)
53
+ case provider
54
+ when :anthropic
55
+ # Anthropic doesn't have a public /v1/models endpoint
56
+ [
57
+ 'claude-3-5-sonnet-20241022',
58
+ 'claude-3-opus-20240229',
59
+ 'claude-3-sonnet-20240229',
60
+ 'claude-3-haiku-20240307'
61
+ ]
62
+ when :openai
63
+ fetch_openai_models(api_key)
64
+ when :openai_compatible
65
+ fetch_openai_compatible_models(endpoint, api_key)
66
+ end
67
+ rescue StandardError => e
68
+ CLI::Formatters::ProgressFormatter.warn("Could not fetch models: #{e.message}")
69
+ nil
70
+ end
71
+
72
+ # Get provider display information
73
+ #
74
+ # @param provider [Symbol] Provider type
75
+ # @return [Hash] Hash with :name, :docs_url keys
76
+ def provider_info(provider)
77
+ case provider
78
+ when :anthropic
79
+ { name: 'Anthropic', docs_url: 'https://console.anthropic.com' }
80
+ when :openai
81
+ { name: 'OpenAI', docs_url: 'https://platform.openai.com/api-keys' }
82
+ when :openai_compatible
83
+ { name: 'OpenAI-Compatible', docs_url: nil }
84
+ else
85
+ { name: provider.to_s.capitalize, docs_url: nil }
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def test_anthropic(api_key)
92
+ client = RubyLLM.new(provider: :anthropic, api_key: api_key)
93
+ client.chat(
94
+ [{ role: 'user', content: 'Test' }],
95
+ model: 'claude-3-5-sonnet-20241022',
96
+ max_tokens: 10
97
+ )
98
+ { success: true }
99
+ rescue StandardError => e
100
+ { success: false, error: e.message }
101
+ end
102
+
103
+ def test_openai(api_key)
104
+ client = RubyLLM.new(provider: :openai, api_key: api_key)
105
+ client.chat(
106
+ [{ role: 'user', content: 'Test' }],
107
+ model: 'gpt-4-turbo',
108
+ max_tokens: 10
109
+ )
110
+ { success: true }
111
+ rescue StandardError => e
112
+ { success: false, error: e.message }
113
+ end
114
+
115
+ def test_openai_compatible(endpoint, api_key = nil)
116
+ # For OpenAI-compatible endpoints, we don't make a test request
117
+ # as we can't know what model to use. Just verify the endpoint is reachable.
118
+ fetch_openai_compatible_models(endpoint, api_key)
119
+ { success: true }
120
+ rescue StandardError => e
121
+ { success: false, error: e.message }
122
+ end
123
+
124
+ def fetch_openai_models(api_key)
125
+ fetch_models_from_endpoint('https://api.openai.com', api_key)
126
+ end
127
+
128
+ def fetch_openai_compatible_models(endpoint, api_key)
129
+ return nil unless endpoint
130
+
131
+ fetch_models_from_endpoint(endpoint, api_key)
132
+ end
133
+
134
+ def fetch_models_from_endpoint(base_url, api_key)
135
+ models_url = URI.join(
136
+ base_url.end_with?('/') ? base_url : "#{base_url}/",
137
+ 'v1/models'
138
+ ).to_s
139
+
140
+ uri = URI(models_url)
141
+ request = Net::HTTP::Get.new(uri)
142
+ request['Authorization'] = "Bearer #{api_key}" if api_key
143
+ request['Content-Type'] = 'application/json'
144
+
145
+ response = Net::HTTP.start(
146
+ uri.hostname,
147
+ uri.port,
148
+ use_ssl: uri.scheme == 'https',
149
+ read_timeout: 10
150
+ ) do |http|
151
+ http.request(request)
152
+ end
153
+
154
+ return nil unless response.is_a?(Net::HTTPSuccess)
155
+
156
+ data = JSON.parse(response.body)
157
+ models = data['data']&.map { |m| m['id'] } || []
158
+
159
+ # Filter out fine-tuned/snapshot models for better UX
160
+ models.reject { |m| m.include?('ft-') || m.include?(':') }
161
+ rescue StandardError
162
+ nil
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../cli/helpers/schedule_builder'
5
+
6
+ module LanguageOperator
7
+ module Ux
8
+ # Interactive flow for creating agents
9
+ #
10
+ # Guides users through task description, scheduling, and tool configuration.
11
+ #
12
+ # @example
13
+ # description = Ux::CreateAgent.execute(ctx)
14
+ #
15
+ # rubocop:disable Metrics/ClassLength, Metrics/AbcSize
16
+ class CreateAgent < Base
17
+ # Execute the agent creation flow
18
+ #
19
+ # @return [String, nil] Generated description or nil if cancelled
20
+ def execute
21
+ show_welcome
22
+
23
+ # Step 1: Get task description
24
+ task = ask_task_description
25
+ return nil unless task
26
+
27
+ # Step 2: Determine schedule
28
+ schedule_info = ask_schedule
29
+ return nil unless schedule_info
30
+
31
+ # Step 3: Tool detection and configuration
32
+ tools_config = configure_tools(task)
33
+
34
+ # Step 4: Preview and confirm
35
+ description = build_description(task, schedule_info, tools_config)
36
+
37
+ show_preview(description, schedule_info, tools_config)
38
+
39
+ return nil unless confirm_creation?
40
+
41
+ description
42
+ end
43
+
44
+ private
45
+
46
+ def show_welcome
47
+ heading('Model quick start')
48
+ end
49
+
50
+ def ask_task_description
51
+ prompt.ask('What should your agent do?') do |q|
52
+ q.required true
53
+ q.validate(/\w+/)
54
+ q.messages[:valid?] = 'Please describe a task (cannot be empty)'
55
+ end
56
+ rescue TTY::Reader::InputInterrupt
57
+ CLI::Formatters::ProgressFormatter.error('Cancelled')
58
+ nil
59
+ end
60
+
61
+ def ask_schedule
62
+ puts
63
+ schedule_type = prompt.select('How often should it run?') do |menu|
64
+ menu.choice 'Every day at a specific time', :daily
65
+ menu.choice 'Every few minutes/hours', :interval
66
+ menu.choice 'Continuously (whenever something changes)', :continuous
67
+ menu.choice 'Only when I trigger it manually', :manual
68
+ end
69
+
70
+ case schedule_type
71
+ when :daily
72
+ ask_daily_schedule
73
+ when :interval
74
+ ask_interval_schedule
75
+ when :continuous
76
+ { type: :continuous, description: 'continuously' }
77
+ when :manual
78
+ { type: :manual, description: 'on manual trigger' }
79
+ end
80
+ rescue TTY::Reader::InputInterrupt
81
+ CLI::Formatters::ProgressFormatter.error('Cancelled')
82
+ nil
83
+ end
84
+
85
+ def ask_daily_schedule
86
+ puts
87
+ time_input = prompt.ask('What time each day? (e.g., 4pm, 9:30am, 16:00):') do |q|
88
+ q.required true
89
+ q.validate(lambda do |input|
90
+ CLI::Helpers::ScheduleBuilder.parse_time(input)
91
+ true
92
+ rescue ArgumentError
93
+ false
94
+ end)
95
+ q.messages[:valid?] = 'Invalid time format. Try: 4pm, 9:30am, or 16:00'
96
+ end
97
+
98
+ time_24h = CLI::Helpers::ScheduleBuilder.parse_time(time_input)
99
+ cron = CLI::Helpers::ScheduleBuilder.daily_cron(time_24h)
100
+
101
+ {
102
+ type: :daily,
103
+ time: time_24h,
104
+ cron: cron,
105
+ description: "daily at #{time_input}"
106
+ }
107
+ end
108
+
109
+ def ask_interval_schedule
110
+ puts
111
+ interval = prompt.ask('How often?', convert: :int) do |q|
112
+ q.required true
113
+ q.validate(->(v) { v.to_i.positive? })
114
+ q.messages[:valid?] = 'Please enter a positive number'
115
+ end
116
+
117
+ unit = prompt.select('Minutes, hours, or days?', %w[minutes hours days])
118
+
119
+ cron = CLI::Helpers::ScheduleBuilder.interval_cron(interval, unit)
120
+
121
+ {
122
+ type: :interval,
123
+ interval: interval,
124
+ unit: unit,
125
+ cron: cron,
126
+ description: "every #{interval} #{unit}"
127
+ }
128
+ end
129
+
130
+ def configure_tools(task_description)
131
+ detected = detect_tools(task_description)
132
+ config = {}
133
+
134
+ return config if detected.empty?
135
+
136
+ puts
137
+ puts "I detected these tools: #{pastel.yellow(detected.join(', '))}"
138
+ puts
139
+
140
+ detected.each do |tool|
141
+ case tool
142
+ when 'email'
143
+ config[:email] = ask_email_config
144
+ when 'google-sheets', 'spreadsheet'
145
+ config[:spreadsheet] = ask_spreadsheet_config
146
+ when 'slack'
147
+ config[:slack] = ask_slack_config
148
+ end
149
+ end
150
+
151
+ config[:tools] = detected
152
+ config
153
+ end
154
+
155
+ def detect_tools(description)
156
+ tools = []
157
+ text = description.downcase
158
+
159
+ tools << 'email' if text.match?(/email|mail|send.*message/i)
160
+ tools << 'google-sheets' if text.match?(/spreadsheet|sheet|excel|csv/i)
161
+ tools << 'slack' if text.match?(/slack/i)
162
+ tools << 'github' if text.match?(/github|git|repo/i)
163
+
164
+ tools
165
+ end
166
+
167
+ def ask_email_config
168
+ email = prompt.ask('Your email for notifications:') do |q|
169
+ q.required true
170
+ q.validate(/\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i)
171
+ q.messages[:valid?] = 'Please enter a valid email address'
172
+ end
173
+ { address: email }
174
+ end
175
+
176
+ def ask_spreadsheet_config
177
+ url = prompt.ask('Spreadsheet URL:') do |q|
178
+ q.required true
179
+ q.validate(%r{^https?://}i)
180
+ q.messages[:valid?] = 'Please enter a valid URL'
181
+ end
182
+ { url: url }
183
+ end
184
+
185
+ def ask_slack_config
186
+ channel = prompt.ask('Slack channel (e.g., #general):') do |q|
187
+ q.required true
188
+ end
189
+ { channel: channel }
190
+ end
191
+
192
+ def build_description(task, schedule_info, tools_config)
193
+ parts = [task]
194
+
195
+ # Add schedule information
196
+ case schedule_info[:type]
197
+ when :daily
198
+ parts << schedule_info[:description]
199
+ when :interval
200
+ parts << schedule_info[:description]
201
+ when :continuous
202
+ # "continuously" might already be implied in task
203
+ parts << 'continuously' unless task.downcase.include?('continuous')
204
+ when :manual
205
+ # Manual trigger doesn't need to be in description
206
+ end
207
+
208
+ # Add tool-specific details
209
+ parts << "and email me at #{tools_config[:email][:address]}" if tools_config[:email]
210
+
211
+ if tools_config[:spreadsheet] && !task.include?('http')
212
+ # Replace generic "spreadsheet" with specific URL if not already present
213
+ parts << "using spreadsheet at #{tools_config[:spreadsheet][:url]}"
214
+ end
215
+
216
+ parts << "and send results to #{tools_config[:slack][:channel]}" if tools_config[:slack]
217
+
218
+ parts.join(' ')
219
+ end
220
+
221
+ def show_preview(description, schedule_info, tools_config)
222
+ puts
223
+ puts pastel.cyan('╭─ Preview ──────────────────────────────╮')
224
+ puts '│'
225
+ puts "│ #{pastel.bold('Task:')} #{description}"
226
+
227
+ if schedule_info[:type] == :manual
228
+ puts "│ #{pastel.bold('Mode:')} Manual trigger"
229
+ else
230
+ schedule_text = schedule_info[:description] || 'on demand'
231
+ puts "│ #{pastel.bold('Schedule:')} #{schedule_text}"
232
+ end
233
+
234
+ puts "│ #{pastel.bold('Cron:')} #{pastel.dim(schedule_info[:cron])}" if schedule_info[:cron]
235
+
236
+ puts "│ #{pastel.bold('Tools:')} #{tools_config[:tools].join(', ')}" if tools_config[:tools]&.any?
237
+
238
+ puts '│'
239
+ puts pastel.cyan('╰────────────────────────────────────────╯')
240
+ puts
241
+ end
242
+
243
+ def confirm_creation?
244
+ puts
245
+ prompt.yes?('Create this agent?')
246
+ rescue TTY::Reader::InputInterrupt
247
+ false
248
+ end
249
+ end
250
+ # rubocop:enable Metrics/ClassLength, Metrics/AbcSize
251
+ end
252
+ end