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.
- checksums.yaml +4 -4
- data/.rubocop.yml +125 -0
- data/CHANGELOG.md +53 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +284 -0
- data/LICENSE +229 -21
- data/Makefile +77 -0
- data/README.md +3 -11
- data/Rakefile +34 -0
- data/bin/aictl +7 -0
- data/completions/_aictl +232 -0
- data/completions/aictl.bash +121 -0
- data/completions/aictl.fish +114 -0
- data/docs/architecture/agent-runtime.md +585 -0
- data/docs/dsl/agent-reference.md +591 -0
- data/docs/dsl/best-practices.md +1078 -0
- data/docs/dsl/chat-endpoints.md +895 -0
- data/docs/dsl/constraints.md +671 -0
- data/docs/dsl/mcp-integration.md +1177 -0
- data/docs/dsl/webhooks.md +932 -0
- data/docs/dsl/workflows.md +744 -0
- data/examples/README.md +569 -0
- data/examples/agent_example.rb +86 -0
- data/examples/chat_endpoint_agent.rb +118 -0
- data/examples/github_webhook_agent.rb +171 -0
- data/examples/mcp_agent.rb +158 -0
- data/examples/oauth_callback_agent.rb +296 -0
- data/examples/stripe_webhook_agent.rb +219 -0
- data/examples/webhook_agent.rb +80 -0
- data/lib/language_operator/agent/base.rb +110 -0
- data/lib/language_operator/agent/executor.rb +440 -0
- data/lib/language_operator/agent/instrumentation.rb +54 -0
- data/lib/language_operator/agent/metrics_tracker.rb +183 -0
- data/lib/language_operator/agent/safety/ast_validator.rb +272 -0
- data/lib/language_operator/agent/safety/audit_logger.rb +104 -0
- data/lib/language_operator/agent/safety/budget_tracker.rb +175 -0
- data/lib/language_operator/agent/safety/content_filter.rb +93 -0
- data/lib/language_operator/agent/safety/manager.rb +207 -0
- data/lib/language_operator/agent/safety/rate_limiter.rb +150 -0
- data/lib/language_operator/agent/safety/safe_executor.rb +115 -0
- data/lib/language_operator/agent/scheduler.rb +183 -0
- data/lib/language_operator/agent/telemetry.rb +116 -0
- data/lib/language_operator/agent/web_server.rb +610 -0
- data/lib/language_operator/agent/webhook_authenticator.rb +226 -0
- data/lib/language_operator/agent.rb +149 -0
- data/lib/language_operator/cli/commands/agent.rb +1252 -0
- data/lib/language_operator/cli/commands/cluster.rb +335 -0
- data/lib/language_operator/cli/commands/install.rb +404 -0
- data/lib/language_operator/cli/commands/model.rb +266 -0
- data/lib/language_operator/cli/commands/persona.rb +396 -0
- data/lib/language_operator/cli/commands/quickstart.rb +22 -0
- data/lib/language_operator/cli/commands/status.rb +156 -0
- data/lib/language_operator/cli/commands/tool.rb +537 -0
- data/lib/language_operator/cli/commands/use.rb +47 -0
- data/lib/language_operator/cli/errors/handler.rb +180 -0
- data/lib/language_operator/cli/errors/suggestions.rb +176 -0
- data/lib/language_operator/cli/formatters/code_formatter.rb +81 -0
- data/lib/language_operator/cli/formatters/log_formatter.rb +290 -0
- data/lib/language_operator/cli/formatters/progress_formatter.rb +53 -0
- data/lib/language_operator/cli/formatters/table_formatter.rb +179 -0
- data/lib/language_operator/cli/formatters/value_formatter.rb +113 -0
- data/lib/language_operator/cli/helpers/cluster_context.rb +62 -0
- data/lib/language_operator/cli/helpers/cluster_validator.rb +101 -0
- data/lib/language_operator/cli/helpers/editor_helper.rb +58 -0
- data/lib/language_operator/cli/helpers/kubeconfig_validator.rb +167 -0
- data/lib/language_operator/cli/helpers/resource_dependency_checker.rb +74 -0
- data/lib/language_operator/cli/helpers/schedule_builder.rb +108 -0
- data/lib/language_operator/cli/helpers/user_prompts.rb +69 -0
- data/lib/language_operator/cli/main.rb +232 -0
- data/lib/language_operator/cli/templates/tools/generic.yaml +66 -0
- data/lib/language_operator/cli/wizards/agent_wizard.rb +246 -0
- data/lib/language_operator/cli/wizards/quickstart_wizard.rb +588 -0
- data/lib/language_operator/client/base.rb +214 -0
- data/lib/language_operator/client/config.rb +136 -0
- data/lib/language_operator/client/cost_calculator.rb +37 -0
- data/lib/language_operator/client/mcp_connector.rb +123 -0
- data/lib/language_operator/client.rb +19 -0
- data/lib/language_operator/config/cluster_config.rb +101 -0
- data/lib/language_operator/config/tool_patterns.yaml +57 -0
- data/lib/language_operator/config/tool_registry.rb +96 -0
- data/lib/language_operator/config.rb +138 -0
- data/lib/language_operator/dsl/adapter.rb +124 -0
- data/lib/language_operator/dsl/agent_context.rb +90 -0
- data/lib/language_operator/dsl/agent_definition.rb +427 -0
- data/lib/language_operator/dsl/chat_endpoint_definition.rb +115 -0
- data/lib/language_operator/dsl/config.rb +119 -0
- data/lib/language_operator/dsl/context.rb +50 -0
- data/lib/language_operator/dsl/execution_context.rb +47 -0
- data/lib/language_operator/dsl/helpers.rb +109 -0
- data/lib/language_operator/dsl/http.rb +184 -0
- data/lib/language_operator/dsl/mcp_server_definition.rb +73 -0
- data/lib/language_operator/dsl/parameter_definition.rb +124 -0
- data/lib/language_operator/dsl/registry.rb +36 -0
- data/lib/language_operator/dsl/shell.rb +125 -0
- data/lib/language_operator/dsl/tool_definition.rb +112 -0
- data/lib/language_operator/dsl/webhook_authentication.rb +114 -0
- data/lib/language_operator/dsl/webhook_definition.rb +106 -0
- data/lib/language_operator/dsl/workflow_definition.rb +259 -0
- data/lib/language_operator/dsl.rb +160 -0
- data/lib/language_operator/errors.rb +60 -0
- data/lib/language_operator/kubernetes/client.rb +279 -0
- data/lib/language_operator/kubernetes/resource_builder.rb +194 -0
- data/lib/language_operator/loggable.rb +47 -0
- data/lib/language_operator/logger.rb +141 -0
- data/lib/language_operator/retry.rb +123 -0
- data/lib/language_operator/retryable.rb +132 -0
- data/lib/language_operator/tool_loader.rb +242 -0
- data/lib/language_operator/validators.rb +170 -0
- data/lib/language_operator/version.rb +1 -1
- data/lib/language_operator.rb +65 -3
- data/requirements/tasks/challenge.md +9 -0
- data/requirements/tasks/iterate.md +36 -0
- data/requirements/tasks/optimize.md +21 -0
- data/requirements/tasks/tag.md +5 -0
- data/test_agent_dsl.rb +108 -0
- 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
|