language-operator 0.0.1 → 0.1.31

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