language-operator 0.0.1 → 0.1.30

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +125 -0
  3. data/CHANGELOG.md +53 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +284 -0
  6. data/LICENSE +229 -21
  7. data/Makefile +77 -0
  8. data/README.md +3 -11
  9. data/Rakefile +34 -0
  10. data/bin/aictl +7 -0
  11. data/completions/_aictl +232 -0
  12. data/completions/aictl.bash +121 -0
  13. data/completions/aictl.fish +114 -0
  14. data/docs/architecture/agent-runtime.md +585 -0
  15. data/docs/dsl/agent-reference.md +591 -0
  16. data/docs/dsl/best-practices.md +1078 -0
  17. data/docs/dsl/chat-endpoints.md +895 -0
  18. data/docs/dsl/constraints.md +671 -0
  19. data/docs/dsl/mcp-integration.md +1177 -0
  20. data/docs/dsl/webhooks.md +932 -0
  21. data/docs/dsl/workflows.md +744 -0
  22. data/examples/README.md +569 -0
  23. data/examples/agent_example.rb +86 -0
  24. data/examples/chat_endpoint_agent.rb +118 -0
  25. data/examples/github_webhook_agent.rb +171 -0
  26. data/examples/mcp_agent.rb +158 -0
  27. data/examples/oauth_callback_agent.rb +296 -0
  28. data/examples/stripe_webhook_agent.rb +219 -0
  29. data/examples/webhook_agent.rb +80 -0
  30. data/lib/language_operator/agent/base.rb +110 -0
  31. data/lib/language_operator/agent/executor.rb +440 -0
  32. data/lib/language_operator/agent/instrumentation.rb +54 -0
  33. data/lib/language_operator/agent/metrics_tracker.rb +183 -0
  34. data/lib/language_operator/agent/safety/ast_validator.rb +272 -0
  35. data/lib/language_operator/agent/safety/audit_logger.rb +104 -0
  36. data/lib/language_operator/agent/safety/budget_tracker.rb +175 -0
  37. data/lib/language_operator/agent/safety/content_filter.rb +93 -0
  38. data/lib/language_operator/agent/safety/manager.rb +207 -0
  39. data/lib/language_operator/agent/safety/rate_limiter.rb +150 -0
  40. data/lib/language_operator/agent/safety/safe_executor.rb +115 -0
  41. data/lib/language_operator/agent/scheduler.rb +183 -0
  42. data/lib/language_operator/agent/telemetry.rb +116 -0
  43. data/lib/language_operator/agent/web_server.rb +610 -0
  44. data/lib/language_operator/agent/webhook_authenticator.rb +226 -0
  45. data/lib/language_operator/agent.rb +149 -0
  46. data/lib/language_operator/cli/commands/agent.rb +1252 -0
  47. data/lib/language_operator/cli/commands/cluster.rb +335 -0
  48. data/lib/language_operator/cli/commands/install.rb +404 -0
  49. data/lib/language_operator/cli/commands/model.rb +266 -0
  50. data/lib/language_operator/cli/commands/persona.rb +396 -0
  51. data/lib/language_operator/cli/commands/quickstart.rb +22 -0
  52. data/lib/language_operator/cli/commands/status.rb +156 -0
  53. data/lib/language_operator/cli/commands/tool.rb +537 -0
  54. data/lib/language_operator/cli/commands/use.rb +47 -0
  55. data/lib/language_operator/cli/errors/handler.rb +180 -0
  56. data/lib/language_operator/cli/errors/suggestions.rb +176 -0
  57. data/lib/language_operator/cli/formatters/code_formatter.rb +81 -0
  58. data/lib/language_operator/cli/formatters/log_formatter.rb +290 -0
  59. data/lib/language_operator/cli/formatters/progress_formatter.rb +53 -0
  60. data/lib/language_operator/cli/formatters/table_formatter.rb +179 -0
  61. data/lib/language_operator/cli/formatters/value_formatter.rb +113 -0
  62. data/lib/language_operator/cli/helpers/cluster_context.rb +62 -0
  63. data/lib/language_operator/cli/helpers/cluster_validator.rb +101 -0
  64. data/lib/language_operator/cli/helpers/editor_helper.rb +58 -0
  65. data/lib/language_operator/cli/helpers/kubeconfig_validator.rb +167 -0
  66. data/lib/language_operator/cli/helpers/resource_dependency_checker.rb +74 -0
  67. data/lib/language_operator/cli/helpers/schedule_builder.rb +108 -0
  68. data/lib/language_operator/cli/helpers/user_prompts.rb +69 -0
  69. data/lib/language_operator/cli/main.rb +232 -0
  70. data/lib/language_operator/cli/templates/tools/generic.yaml +66 -0
  71. data/lib/language_operator/cli/wizards/agent_wizard.rb +246 -0
  72. data/lib/language_operator/cli/wizards/quickstart_wizard.rb +588 -0
  73. data/lib/language_operator/client/base.rb +214 -0
  74. data/lib/language_operator/client/config.rb +136 -0
  75. data/lib/language_operator/client/cost_calculator.rb +37 -0
  76. data/lib/language_operator/client/mcp_connector.rb +123 -0
  77. data/lib/language_operator/client.rb +19 -0
  78. data/lib/language_operator/config/cluster_config.rb +101 -0
  79. data/lib/language_operator/config/tool_patterns.yaml +57 -0
  80. data/lib/language_operator/config/tool_registry.rb +96 -0
  81. data/lib/language_operator/config.rb +138 -0
  82. data/lib/language_operator/dsl/adapter.rb +124 -0
  83. data/lib/language_operator/dsl/agent_context.rb +90 -0
  84. data/lib/language_operator/dsl/agent_definition.rb +427 -0
  85. data/lib/language_operator/dsl/chat_endpoint_definition.rb +115 -0
  86. data/lib/language_operator/dsl/config.rb +119 -0
  87. data/lib/language_operator/dsl/context.rb +50 -0
  88. data/lib/language_operator/dsl/execution_context.rb +47 -0
  89. data/lib/language_operator/dsl/helpers.rb +109 -0
  90. data/lib/language_operator/dsl/http.rb +184 -0
  91. data/lib/language_operator/dsl/mcp_server_definition.rb +73 -0
  92. data/lib/language_operator/dsl/parameter_definition.rb +124 -0
  93. data/lib/language_operator/dsl/registry.rb +36 -0
  94. data/lib/language_operator/dsl/shell.rb +125 -0
  95. data/lib/language_operator/dsl/tool_definition.rb +112 -0
  96. data/lib/language_operator/dsl/webhook_authentication.rb +114 -0
  97. data/lib/language_operator/dsl/webhook_definition.rb +106 -0
  98. data/lib/language_operator/dsl/workflow_definition.rb +259 -0
  99. data/lib/language_operator/dsl.rb +160 -0
  100. data/lib/language_operator/errors.rb +60 -0
  101. data/lib/language_operator/kubernetes/client.rb +279 -0
  102. data/lib/language_operator/kubernetes/resource_builder.rb +194 -0
  103. data/lib/language_operator/loggable.rb +47 -0
  104. data/lib/language_operator/logger.rb +141 -0
  105. data/lib/language_operator/retry.rb +123 -0
  106. data/lib/language_operator/retryable.rb +132 -0
  107. data/lib/language_operator/tool_loader.rb +242 -0
  108. data/lib/language_operator/validators.rb +170 -0
  109. data/lib/language_operator/version.rb +1 -1
  110. data/lib/language_operator.rb +65 -3
  111. data/requirements/tasks/challenge.md +9 -0
  112. data/requirements/tasks/iterate.md +36 -0
  113. data/requirements/tasks/optimize.md +21 -0
  114. data/requirements/tasks/tag.md +5 -0
  115. data/test_agent_dsl.rb +108 -0
  116. metadata +503 -20
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pastel'
4
+ require 'json'
5
+ require 'time'
6
+
7
+ module LanguageOperator
8
+ module CLI
9
+ module Formatters
10
+ # Formatter for displaying agent execution logs with color and icons
11
+ class LogFormatter
12
+ class << self
13
+ # Format a single log line from kubectl output
14
+ #
15
+ # @param line [String] Raw log line from kubectl (with [pod/container] prefix)
16
+ # @return [String] Formatted log line with colors and icons
17
+ def format_line(line)
18
+ return line if line.strip.empty?
19
+
20
+ # Parse kubectl prefix: [pod-name/container-name] log_content
21
+ prefix, content = parse_kubectl_prefix(line)
22
+
23
+ # Format the log content based on detected format
24
+ formatted_content = format_log_content(content)
25
+
26
+ # Combine with dimmed prefix
27
+ if prefix
28
+ "#{pastel.dim(prefix)} #{formatted_content}"
29
+ else
30
+ formatted_content
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def pastel
37
+ @pastel ||= Pastel.new
38
+ end
39
+
40
+ # Parse the kubectl prefix from the log line
41
+ # Returns [prefix, content] or [nil, original_line]
42
+ def parse_kubectl_prefix(line)
43
+ if line =~ /\A\[([^\]]+)\]\s+(.*)/
44
+ prefix = "[#{Regexp.last_match(1)}]"
45
+ content = Regexp.last_match(2)
46
+ [prefix, content]
47
+ else
48
+ [nil, line]
49
+ end
50
+ end
51
+
52
+ # Format log content based on detected format (pretty/text/json)
53
+ def format_log_content(content)
54
+ # Try JSON first
55
+ if content.strip.start_with?('{')
56
+ format_json_log(content)
57
+ # Check for text format with timestamp
58
+ elsif content =~ /\A\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}/
59
+ format_text_log(content)
60
+ # Default to pretty format
61
+ else
62
+ format_pretty_log(content)
63
+ end
64
+ rescue StandardError
65
+ # Fallback to plain if parsing fails
66
+ content
67
+ end
68
+
69
+ # Format JSON log format
70
+ def format_json_log(content)
71
+ data = JSON.parse(content)
72
+ timestamp = format_timestamp_from_iso(data['timestamp'])
73
+ level = data['level']
74
+ message = data['message']
75
+
76
+ # Build formatted line
77
+ formatted = "#{timestamp} #{format_message_with_icon(message, level)}"
78
+
79
+ # Add metadata if present
80
+ metadata_keys = data.keys - %w[timestamp level component message]
81
+ if metadata_keys.any?
82
+ metadata_str = metadata_keys.map { |k| "#{k}=#{data[k]}" }.join(', ')
83
+ formatted += " #{pastel.dim("(#{metadata_str})")}"
84
+ end
85
+
86
+ formatted
87
+ rescue JSON::ParserError
88
+ content
89
+ end
90
+
91
+ # Format text log format: 2024-11-07 14:32:15 INFO [Component] message
92
+ def format_text_log(content)
93
+ if content =~ /\A(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+(\w+)\s+(?:\[([^\]]+)\]\s+)?(.*)/
94
+ timestamp_str = Regexp.last_match(1)
95
+ level = Regexp.last_match(2)
96
+ _component = Regexp.last_match(3)
97
+ message = Regexp.last_match(4)
98
+
99
+ timestamp = format_timestamp_from_text(timestamp_str)
100
+ "#{timestamp} #{format_message_with_icon(message, level)}"
101
+ else
102
+ content
103
+ end
104
+ end
105
+
106
+ # Format pretty log format: emoji message (metadata)
107
+ def format_pretty_log(content)
108
+ # Extract emoji and message
109
+ if content =~ /\A([^\s]+)\s+(.*)/
110
+ emoji_or_text = Regexp.last_match(1)
111
+ rest = Regexp.last_match(2)
112
+
113
+ # Check if first part is an emoji (common log emojis)
114
+ if emoji_or_text =~ /[▶👤◆🤖→✓✅✗❌⚠️🔍ℹ🔄]/
115
+ level = emoji_to_level(emoji_or_text)
116
+ # Message already has emoji, just format rest without adding another icon
117
+ message_text, metadata = extract_metadata(rest)
118
+ color = determine_color_from_level(level)
119
+ formatted = pastel.send(color, "#{emoji_or_text} #{message_text}")
120
+ formatted += " #{format_metadata(metadata)}" if metadata
121
+ formatted
122
+ else
123
+ format_message_with_icon(content, 'INFO')
124
+ end
125
+ else
126
+ format_message_with_icon(content, 'INFO')
127
+ end
128
+ end
129
+
130
+ # Format a message with appropriate icon and color based on content and level
131
+ def format_message_with_icon(message, level)
132
+ # Extract metadata from message (key=value pairs in parens)
133
+ message_text, metadata = extract_metadata(message)
134
+
135
+ # Determine icon and color based on message content
136
+ icon, color = determine_icon_and_color(message_text, level)
137
+
138
+ # Format the main message
139
+ formatted = pastel.send(color, "#{icon} #{message_text}")
140
+
141
+ # Add metadata if present
142
+ formatted += " #{format_metadata(metadata)}" if metadata
143
+
144
+ formatted
145
+ end
146
+
147
+ # Extract metadata from message
148
+ # Returns [message_without_metadata, metadata_hash]
149
+ def extract_metadata(message)
150
+ if message =~ /\A(.*?)\s*\(([^)]+)\)\s*\z/
151
+ message_text = Regexp.last_match(1)
152
+ metadata_str = Regexp.last_match(2)
153
+
154
+ # Parse key=value pairs
155
+ metadata = {}
156
+ metadata_str.scan(/(\w+)=([^,]+)(?:,\s*)?/) do |key, value|
157
+ metadata[key] = value.strip
158
+ end
159
+
160
+ [message_text, metadata]
161
+ else
162
+ [message, nil]
163
+ end
164
+ end
165
+
166
+ # Determine icon and color based on message content
167
+ def determine_icon_and_color(message, level)
168
+ case message
169
+ when /Starting execution|Starting iteration|Starting autonomous/i
170
+ ['▶', :cyan]
171
+ when /Loading persona|Persona:/i
172
+ ['👤', :cyan]
173
+ when /Connecting to tool|Calling tool|MCP server/i
174
+ ['◆', :blue]
175
+ when /LLM request|Prompt|🤖/i
176
+ ['🤖', :magenta]
177
+ when /Tool completed|result|response|found/i
178
+ ['→', :yellow]
179
+ when /Iteration completed|completed|finished/i
180
+ ['✓', :green]
181
+ when /Execution complete|✅|workflow.*completed/i
182
+ ['✅', :green]
183
+ when /error|fail|✗|❌/i
184
+ ['✗', :red]
185
+ when /warn|⚠️/i
186
+ ['⚠️', :yellow]
187
+ else
188
+ # Default based on level
189
+ case level&.upcase
190
+ when 'ERROR'
191
+ ['✗', :red]
192
+ when 'WARN'
193
+ ['⚠️', :yellow]
194
+ when 'DEBUG'
195
+ ['🔍', :dim]
196
+ else
197
+ ['', :white]
198
+ end
199
+ end
200
+ end
201
+
202
+ # Format metadata hash
203
+ def format_metadata(metadata)
204
+ return '' unless metadata&.any?
205
+
206
+ parts = metadata.map do |key, value|
207
+ # Highlight durations
208
+ if key == 'duration_s' || key.include?('duration')
209
+ duration_val = value.to_f
210
+ formatted_duration = duration_val < 1 ? "#{(duration_val * 1000).round}ms" : "#{duration_val.round(1)}s"
211
+ "duration=#{pastel.yellow(formatted_duration)}"
212
+ # Highlight counts and numbers
213
+ elsif value =~ /^\d+$/
214
+ "#{key}=#{pastel.yellow(value)}"
215
+ # Highlight tool names
216
+ elsif %w[tool name].include?(key)
217
+ "#{key}=#{pastel.bold(value)}"
218
+ else
219
+ "#{key}=#{value}"
220
+ end
221
+ end
222
+
223
+ pastel.dim("(#{parts.join(', ')})")
224
+ end
225
+
226
+ # Format timestamp from ISO8601 format
227
+ def format_timestamp_from_iso(timestamp_str)
228
+ return '' unless timestamp_str
229
+
230
+ time = Time.parse(timestamp_str)
231
+ format_time(time)
232
+ rescue StandardError
233
+ ''
234
+ end
235
+
236
+ # Format timestamp from text format (YYYY-MM-DD HH:MM:SS)
237
+ def format_timestamp_from_text(timestamp_str)
238
+ return '' unless timestamp_str
239
+
240
+ time = Time.parse(timestamp_str)
241
+ format_time(time)
242
+ rescue StandardError
243
+ ''
244
+ end
245
+
246
+ # Format Time object as HH:MM:SS
247
+ def format_time(time)
248
+ pastel.dim(time.strftime('%H:%M:%S'))
249
+ end
250
+
251
+ # Convert emoji to log level
252
+ def emoji_to_level(emoji)
253
+ case emoji
254
+ when 'ℹ️', 'ℹ'
255
+ 'INFO'
256
+ when '🔍'
257
+ 'DEBUG'
258
+ when '⚠️', '⚠'
259
+ 'WARN'
260
+ when '❌', '✗'
261
+ 'ERROR'
262
+ when '▶', '👤', '◆'
263
+ 'INFO'
264
+ when '🤖'
265
+ 'INFO'
266
+ when '→', '✓', '✅'
267
+ 'INFO'
268
+ else
269
+ 'INFO'
270
+ end
271
+ end
272
+
273
+ # Determine color from log level
274
+ def determine_color_from_level(level)
275
+ case level&.upcase
276
+ when 'ERROR'
277
+ :red
278
+ when 'WARN'
279
+ :yellow
280
+ when 'DEBUG'
281
+ :dim
282
+ else
283
+ :white
284
+ end
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-spinner'
4
+ require 'pastel'
5
+
6
+ module LanguageOperator
7
+ module CLI
8
+ module Formatters
9
+ # Beautiful progress output for CLI operations
10
+ class ProgressFormatter
11
+ class << self
12
+ def with_spinner(message, success_msg: nil, &block)
13
+ spinner = TTY::Spinner.new("[:spinner] #{message}...", format: :dots, success_mark: pastel.green('✔'))
14
+ spinner.auto_spin
15
+
16
+ result = block.call
17
+
18
+ # Determine what to show after spinner completes
19
+ final_status = success_msg || 'done'
20
+
21
+ spinner.success(final_status)
22
+ result
23
+ rescue StandardError => e
24
+ spinner.error(e.message)
25
+ raise
26
+ end
27
+
28
+ def success(message)
29
+ puts "[#{pastel.green('✔')}] #{message}"
30
+ end
31
+
32
+ def error(message)
33
+ puts "[#{pastel.red('✗')}] #{message}"
34
+ end
35
+
36
+ def info(message)
37
+ puts pastel.dim(message)
38
+ end
39
+
40
+ def warn(message)
41
+ puts "[#{pastel.yellow('⚠')}] #{message}"
42
+ end
43
+
44
+ private
45
+
46
+ def pastel
47
+ @pastel ||= Pastel.new
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-table'
4
+ require 'pastel'
5
+
6
+ module LanguageOperator
7
+ module CLI
8
+ module Formatters
9
+ # Table output for CLI list commands
10
+ class TableFormatter
11
+ class << self
12
+ def clusters(clusters)
13
+ return ProgressFormatter.info('No clusters found') if clusters.empty?
14
+
15
+ headers = %w[NAME NAMESPACE AGENTS TOOLS MODELS STATUS]
16
+ rows = clusters.map do |cluster|
17
+ [
18
+ cluster[:name],
19
+ cluster[:namespace],
20
+ cluster[:agents] || 0,
21
+ cluster[:tools] || 0,
22
+ cluster[:models] || 0,
23
+ status_indicator(cluster[:status])
24
+ ]
25
+ end
26
+
27
+ table = TTY::Table.new(headers, rows)
28
+ puts table.render(:unicode, padding: [0, 1])
29
+ end
30
+
31
+ def agents(agents)
32
+ return ProgressFormatter.info('No agents found') if agents.empty?
33
+
34
+ headers = ['NAME', 'MODE', 'STATUS', 'NEXT RUN', 'EXECUTIONS']
35
+ rows = agents.map do |agent|
36
+ [
37
+ agent[:name],
38
+ agent[:mode],
39
+ status_indicator(agent[:status]),
40
+ agent[:next_run] || 'N/A',
41
+ agent[:executions] || 0
42
+ ]
43
+ end
44
+
45
+ table = TTY::Table.new(headers, rows)
46
+ puts table.render(:unicode, padding: [0, 1])
47
+ end
48
+
49
+ def all_agents(agents_by_cluster)
50
+ return ProgressFormatter.info('No agents found across any cluster') if agents_by_cluster.empty?
51
+
52
+ headers = ['CLUSTER', 'NAME', 'MODE', 'STATUS', 'NEXT RUN', 'EXECUTIONS']
53
+ rows = []
54
+
55
+ agents_by_cluster.each do |cluster_name, agents|
56
+ agents.each do |agent|
57
+ rows << [
58
+ cluster_name,
59
+ agent[:name],
60
+ agent[:mode],
61
+ status_indicator(agent[:status]),
62
+ agent[:next_run] || 'N/A',
63
+ agent[:executions] || 0
64
+ ]
65
+ end
66
+ end
67
+
68
+ table = TTY::Table.new(headers, rows)
69
+ puts table.render(:unicode, padding: [0, 1])
70
+ end
71
+
72
+ def tools(tools)
73
+ return ProgressFormatter.info('No tools found') if tools.empty?
74
+
75
+ headers = ['NAME', 'TYPE', 'STATUS', 'AGENTS USING']
76
+ rows = tools.map do |tool|
77
+ [
78
+ tool[:name],
79
+ tool[:type],
80
+ status_indicator(tool[:status]),
81
+ tool[:agents_using] || 0
82
+ ]
83
+ end
84
+
85
+ table = TTY::Table.new(headers, rows)
86
+ puts table.render(:unicode, padding: [0, 1])
87
+ end
88
+
89
+ def personas(personas)
90
+ return ProgressFormatter.info('No personas found') if personas.empty?
91
+
92
+ headers = ['NAME', 'TONE', 'USED BY', 'DESCRIPTION']
93
+ rows = personas.map do |persona|
94
+ [
95
+ persona[:name],
96
+ persona[:tone],
97
+ persona[:used_by] || 0,
98
+ truncate(persona[:description], 50)
99
+ ]
100
+ end
101
+
102
+ table = TTY::Table.new(headers, rows)
103
+ puts table.render(:unicode, padding: [0, 1])
104
+ end
105
+
106
+ def models(models)
107
+ return ProgressFormatter.info('No models found') if models.empty?
108
+
109
+ headers = %w[NAME PROVIDER MODEL STATUS]
110
+ rows = models.map do |model|
111
+ [
112
+ model[:name],
113
+ model[:provider],
114
+ model[:model],
115
+ status_indicator(model[:status])
116
+ ]
117
+ end
118
+
119
+ table = TTY::Table.new(headers, rows)
120
+ puts table.render(:unicode, padding: [0, 1])
121
+ end
122
+
123
+ def status_dashboard(cluster_summary, current_cluster: nil)
124
+ return ProgressFormatter.info('No clusters configured') if cluster_summary.empty?
125
+
126
+ headers = %w[CLUSTER AGENTS TOOLS MODELS STATUS]
127
+ rows = cluster_summary.map do |cluster|
128
+ name = cluster[:name].to_s
129
+ name += ' *' if current_cluster && cluster[:name] == current_cluster
130
+
131
+ [
132
+ name,
133
+ cluster[:agents] || 0,
134
+ cluster[:tools] || 0,
135
+ cluster[:models] || 0,
136
+ status_indicator(cluster[:status])
137
+ ]
138
+ end
139
+
140
+ table = TTY::Table.new(headers, rows)
141
+ puts table.render(:unicode, padding: [0, 1])
142
+
143
+ return unless current_cluster
144
+
145
+ puts
146
+ puts '* = current cluster'
147
+ end
148
+
149
+ private
150
+
151
+ def status_indicator(status)
152
+ case status&.downcase
153
+ when 'ready', 'running', 'active'
154
+ "#{pastel.green('●')} #{status}"
155
+ when 'pending', 'creating', 'synthesizing'
156
+ "#{pastel.yellow('●')} #{status}"
157
+ when 'failed', 'error'
158
+ "#{pastel.red('●')} #{status}"
159
+ when 'paused', 'stopped'
160
+ "#{pastel.dim('●')} #{status}"
161
+ else
162
+ "#{pastel.dim('●')} #{status || 'Unknown'}"
163
+ end
164
+ end
165
+
166
+ def truncate(text, length)
167
+ return text if text.nil? || text.length <= length
168
+
169
+ "#{text[0...(length - 3)]}..."
170
+ end
171
+
172
+ def pastel
173
+ @pastel ||= Pastel.new
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LanguageOperator
4
+ module CLI
5
+ module Formatters
6
+ # Utility module for formatting values (time, duration, file sizes, etc.)
7
+ # for display in CLI output. Provides consistent formatting across commands.
8
+ module ValueFormatter
9
+ SECONDS_PER_MINUTE = 60
10
+ SECONDS_PER_HOUR = 3600
11
+ SECONDS_PER_DAY = 86_400
12
+ SECONDS_PER_WEEK = 604_800
13
+ BYTES_PER_KB = 1024
14
+ BYTES_PER_MB = 1024 * 1024
15
+ BYTES_PER_GB = 1024 * 1024 * 1024
16
+
17
+ # Format time until a future event
18
+ #
19
+ # @param future_time [Time] The future time
20
+ # @return [String] Formatted string like "in 5m" or "in 2h 15m"
21
+ #
22
+ # @example
23
+ # ValueFormatter.time_until(Time.now + 300) # => "in 5m"
24
+ def self.time_until(future_time)
25
+ diff = future_time - Time.now
26
+
27
+ if diff.negative?
28
+ 'overdue'
29
+ elsif diff < SECONDS_PER_MINUTE
30
+ "in #{diff.to_i}s"
31
+ elsif diff < SECONDS_PER_HOUR
32
+ minutes = (diff / SECONDS_PER_MINUTE).to_i
33
+ "in #{minutes}m"
34
+ elsif diff < SECONDS_PER_DAY
35
+ hours = (diff / SECONDS_PER_HOUR).to_i
36
+ minutes = ((diff % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE).to_i
37
+ "in #{hours}h #{minutes}m"
38
+ else
39
+ days = (diff / SECONDS_PER_DAY).to_i
40
+ hours = ((diff % SECONDS_PER_DAY) / SECONDS_PER_HOUR).to_i
41
+ "in #{days}d #{hours}h"
42
+ end
43
+ end
44
+
45
+ # Format a duration in seconds
46
+ #
47
+ # @param seconds [Numeric] Duration in seconds
48
+ # @return [String] Formatted string like "1.5s" or "2m 30s"
49
+ #
50
+ # @example
51
+ # ValueFormatter.duration(0.5) # => "500ms"
52
+ # ValueFormatter.duration(90) # => "1m 30s"
53
+ def self.duration(seconds)
54
+ if seconds < 1
55
+ "#{(seconds * 1000).round}ms"
56
+ elsif seconds < SECONDS_PER_MINUTE
57
+ "#{seconds.round(1)}s"
58
+ else
59
+ minutes = (seconds / SECONDS_PER_MINUTE).floor
60
+ secs = (seconds % SECONDS_PER_MINUTE).round
61
+ "#{minutes}m #{secs}s"
62
+ end
63
+ end
64
+
65
+ # Format file size in bytes to human-readable format
66
+ #
67
+ # @param bytes [Integer] File size in bytes
68
+ # @return [String] Formatted string like "1.5KB" or "2.3MB"
69
+ #
70
+ # @example
71
+ # ValueFormatter.file_size(1500) # => "1.5KB"
72
+ def self.file_size(bytes)
73
+ if bytes < BYTES_PER_KB
74
+ "#{bytes}B"
75
+ elsif bytes < BYTES_PER_MB
76
+ "#{(bytes / BYTES_PER_KB.to_f).round(1)}KB"
77
+ elsif bytes < BYTES_PER_GB
78
+ "#{(bytes / BYTES_PER_MB.to_f).round(1)}MB"
79
+ else
80
+ "#{(bytes / BYTES_PER_GB.to_f).round(1)}GB"
81
+ end
82
+ end
83
+
84
+ # Format a timestamp as relative time or absolute date
85
+ #
86
+ # @param time [Time] The timestamp to format
87
+ # @return [String] Formatted string like "5 minutes ago" or "2025-01-15 14:30"
88
+ #
89
+ # @example
90
+ # ValueFormatter.timestamp(Time.now - 300) # => "5 minutes ago"
91
+ def self.timestamp(time)
92
+ now = Time.now
93
+ diff = now - time
94
+
95
+ if diff < SECONDS_PER_MINUTE
96
+ "#{diff.to_i} seconds ago"
97
+ elsif diff < SECONDS_PER_HOUR
98
+ minutes = (diff / SECONDS_PER_MINUTE).to_i
99
+ "#{minutes} minute#{'s' if minutes != 1} ago"
100
+ elsif diff < SECONDS_PER_DAY
101
+ hours = (diff / SECONDS_PER_HOUR).to_i
102
+ "#{hours} hour#{'s' if hours != 1} ago"
103
+ elsif diff < SECONDS_PER_WEEK
104
+ days = (diff / SECONDS_PER_DAY).to_i
105
+ "#{days} day#{'s' if days != 1} ago"
106
+ else
107
+ time.strftime('%Y-%m-%d %H:%M')
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cluster_validator'
4
+
5
+ module LanguageOperator
6
+ module CLI
7
+ module Helpers
8
+ # Encapsulates cluster context (name, config, client) to reduce boilerplate
9
+ # in command implementations.
10
+ #
11
+ # Instead of repeating:
12
+ # cluster = ClusterValidator.get_cluster(options[:cluster])
13
+ # cluster_config = ClusterValidator.get_cluster_config(cluster)
14
+ # k8s = ClusterValidator.kubernetes_client(options[:cluster])
15
+ #
16
+ # Use:
17
+ # ctx = ClusterContext.from_options(options)
18
+ # # Access: ctx.name, ctx.config, ctx.client, ctx.namespace
19
+ class ClusterContext
20
+ attr_reader :name, :config, :client, :namespace
21
+
22
+ # Create ClusterContext from command options hash
23
+ # @param options [Hash] Thor command options (expects :cluster key)
24
+ # @return [ClusterContext] Initialized context
25
+ def self.from_options(options)
26
+ name = ClusterValidator.get_cluster(options[:cluster])
27
+ config = ClusterValidator.get_cluster_config(name)
28
+ client = ClusterValidator.kubernetes_client(options[:cluster])
29
+ new(name, config, client)
30
+ end
31
+
32
+ # Initialize with cluster details
33
+ # @param name [String] Cluster name
34
+ # @param config [Hash] Cluster configuration
35
+ # @param client [LanguageOperator::Kubernetes::Client] K8s client
36
+ def initialize(name, config, client)
37
+ @name = name
38
+ @config = config
39
+ @client = client
40
+ @namespace = config[:namespace]
41
+ end
42
+
43
+ # Build kubectl command args for this cluster context
44
+ # @return [Hash] kubectl arguments
45
+ def kubectl_args
46
+ {
47
+ kubeconfig: config[:kubeconfig] ? "--kubeconfig=#{config[:kubeconfig]}" : '',
48
+ context: config[:context] ? "--context=#{config[:context]}" : '',
49
+ namespace: "-n #{namespace}"
50
+ }
51
+ end
52
+
53
+ # Build kubectl command prefix string
54
+ # @return [String] kubectl command prefix
55
+ def kubectl_prefix
56
+ args = kubectl_args
57
+ "kubectl #{args[:kubeconfig]} #{args[:context]} #{args[:namespace]}".strip.squeeze(' ')
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end