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,1252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require_relative '../formatters/progress_formatter'
5
+ require_relative '../formatters/table_formatter'
6
+ require_relative '../formatters/value_formatter'
7
+ require_relative '../helpers/cluster_validator'
8
+ require_relative '../helpers/cluster_context'
9
+ require_relative '../helpers/user_prompts'
10
+ require_relative '../helpers/editor_helper'
11
+ require_relative '../errors/handler'
12
+ require_relative '../../config/cluster_config'
13
+ require_relative '../../kubernetes/client'
14
+ require_relative '../../kubernetes/resource_builder'
15
+
16
+ module LanguageOperator
17
+ module CLI
18
+ module Commands
19
+ # Agent management commands
20
+ class Agent < Thor
21
+ include Helpers::ClusterValidator
22
+
23
+ desc 'create [DESCRIPTION]', 'Create a new agent with natural language description'
24
+ long_desc <<-DESC
25
+ Create a new autonomous agent by describing what you want it to do in natural language.
26
+
27
+ The operator will synthesize the agent from your description and deploy it to your cluster.
28
+
29
+ Examples:
30
+ aictl agent create "review my spreadsheet at 4pm daily and email me any errors"
31
+ aictl agent create "summarize Hacker News top stories every morning at 8am"
32
+ aictl agent create "monitor my website uptime and alert me if it goes down"
33
+ aictl agent create --wizard # Interactive wizard mode
34
+ DESC
35
+ option :cluster, type: :string, desc: 'Override current cluster context'
36
+ option :create_cluster, type: :string, desc: 'Create cluster if it doesn\'t exist'
37
+ option :name, type: :string, desc: 'Agent name (generated from description if not provided)'
38
+ option :persona, type: :string, desc: 'Persona to use for the agent'
39
+ option :tools, type: :array, desc: 'Tools to make available to the agent'
40
+ option :models, type: :array, desc: 'Models to make available to the agent'
41
+ option :dry_run, type: :boolean, default: false, desc: 'Preview what would be created without applying'
42
+ option :wizard, type: :boolean, default: false, desc: 'Use interactive wizard mode'
43
+ def create(description = nil)
44
+ # Activate wizard mode if --wizard flag or no description provided
45
+ if options[:wizard] || description.nil?
46
+ require_relative '../wizards/agent_wizard'
47
+ wizard = Wizards::AgentWizard.new
48
+ description = wizard.run
49
+
50
+ # User cancelled wizard
51
+ unless description
52
+ Formatters::ProgressFormatter.info('Agent creation cancelled')
53
+ return
54
+ end
55
+ end
56
+
57
+ # Handle --create-cluster flag
58
+ if options[:create_cluster]
59
+ cluster_name = options[:create_cluster]
60
+ unless Config::ClusterConfig.cluster_exists?(cluster_name)
61
+ Formatters::ProgressFormatter.info("Creating cluster '#{cluster_name}'...")
62
+ # Delegate to cluster create command
63
+ require_relative 'cluster'
64
+ Cluster.new.invoke(:create, [cluster_name], switch: true)
65
+ end
66
+ cluster = cluster_name
67
+ else
68
+ # Validate cluster selection (this will exit if none selected)
69
+ cluster = Helpers::ClusterValidator.get_cluster(options[:cluster])
70
+ end
71
+
72
+ cluster_config = Helpers::ClusterValidator.get_cluster_config(cluster)
73
+
74
+ Formatters::ProgressFormatter.info("Creating agent in cluster '#{cluster}'")
75
+ puts
76
+
77
+ # Generate agent name from description if not provided
78
+ agent_name = options[:name] || generate_agent_name(description)
79
+
80
+ # Get models: use specified models, or default to all available models in cluster
81
+ models = options[:models]
82
+ if models.nil? || models.empty?
83
+ k8s = Helpers::ClusterValidator.kubernetes_client(options[:cluster])
84
+ available_models = k8s.list_resources('LanguageModel', namespace: cluster_config[:namespace])
85
+ models = available_models.map { |m| m.dig('metadata', 'name') }
86
+
87
+ Errors::Handler.handle_no_models_available(cluster: cluster) if models.empty?
88
+ end
89
+
90
+ # Build LanguageAgent resource
91
+ agent_resource = Kubernetes::ResourceBuilder.language_agent(
92
+ agent_name,
93
+ instructions: description,
94
+ cluster: cluster_config[:namespace],
95
+ persona: options[:persona],
96
+ tools: options[:tools] || [],
97
+ models: models
98
+ )
99
+
100
+ # Dry-run mode: preview without applying
101
+ if options[:dry_run]
102
+ display_dry_run_preview(agent_resource, cluster, description)
103
+ return
104
+ end
105
+
106
+ # Connect to Kubernetes
107
+ k8s = Helpers::ClusterValidator.kubernetes_client(options[:cluster])
108
+
109
+ # Apply resource to cluster
110
+ Formatters::ProgressFormatter.with_spinner("Creating agent '#{agent_name}'") do
111
+ k8s.apply_resource(agent_resource)
112
+ end
113
+
114
+ # Watch synthesis status
115
+ synthesis_result = watch_synthesis_status(k8s, agent_name, cluster_config[:namespace])
116
+
117
+ # Exit if synthesis failed
118
+ exit 1 unless synthesis_result[:success]
119
+
120
+ # Fetch the updated agent to get complete details
121
+ agent = k8s.get_resource('LanguageAgent', agent_name, cluster_config[:namespace])
122
+
123
+ # Display enhanced success output
124
+ display_agent_created(agent, cluster, description, synthesis_result)
125
+ rescue StandardError => e
126
+ Formatters::ProgressFormatter.error("Failed to create agent: #{e.message}")
127
+ raise if ENV['DEBUG']
128
+
129
+ exit 1
130
+ end
131
+
132
+ desc 'list', 'List all agents in current cluster'
133
+ option :cluster, type: :string, desc: 'Override current cluster context'
134
+ option :all_clusters, type: :boolean, default: false, desc: 'Show agents across all clusters'
135
+ def list
136
+ if options[:all_clusters]
137
+ list_all_clusters
138
+ else
139
+ cluster = Helpers::ClusterValidator.get_cluster(options[:cluster])
140
+ list_cluster_agents(cluster)
141
+ end
142
+ rescue StandardError => e
143
+ Formatters::ProgressFormatter.error("Failed to list agents: #{e.message}")
144
+ raise if ENV['DEBUG']
145
+
146
+ exit 1
147
+ end
148
+
149
+ desc 'inspect NAME', 'Show detailed agent information'
150
+ option :cluster, type: :string, desc: 'Override current cluster context'
151
+ def inspect(name)
152
+ cluster = Helpers::ClusterValidator.get_cluster(options[:cluster])
153
+ cluster_config = Helpers::ClusterValidator.get_cluster_config(cluster)
154
+
155
+ k8s = Helpers::ClusterValidator.kubernetes_client(options[:cluster])
156
+
157
+ agent = k8s.get_resource('LanguageAgent', name, cluster_config[:namespace])
158
+
159
+ puts "Agent: #{name}"
160
+ puts " Cluster: #{cluster}"
161
+ puts " Namespace: #{cluster_config[:namespace]}"
162
+ puts
163
+
164
+ # Status
165
+ status = agent.dig('status', 'phase') || 'Unknown'
166
+ puts "Status: #{format_status(status)}"
167
+ puts
168
+
169
+ # Spec details
170
+ puts 'Configuration:'
171
+ puts " Mode: #{agent.dig('spec', 'mode') || 'autonomous'}"
172
+ puts " Schedule: #{agent.dig('spec', 'schedule') || 'N/A'}" if agent.dig('spec', 'schedule')
173
+ puts " Persona: #{agent.dig('spec', 'persona') || '(auto-selected)'}"
174
+ puts
175
+
176
+ # Instructions
177
+ instructions = agent.dig('spec', 'instructions')
178
+ if instructions
179
+ puts 'Instructions:'
180
+ puts " #{instructions}"
181
+ puts
182
+ end
183
+
184
+ # Tools
185
+ tools = agent.dig('spec', 'tools') || []
186
+ if tools.any?
187
+ puts "Tools (#{tools.length}):"
188
+ tools.each { |tool| puts " - #{tool}" }
189
+ puts
190
+ end
191
+
192
+ # Models
193
+ model_refs = agent.dig('spec', 'modelRefs') || []
194
+ if model_refs.any?
195
+ puts "Models (#{model_refs.length}):"
196
+ model_refs.each { |ref| puts " - #{ref['name']}" }
197
+ puts
198
+ end
199
+
200
+ # Synthesis info
201
+ synthesis = agent.dig('status', 'synthesis')
202
+ if synthesis
203
+ puts 'Synthesis:'
204
+ puts " Status: #{synthesis['status']}"
205
+ puts " Model: #{synthesis['model']}" if synthesis['model']
206
+ puts " Completed: #{synthesis['completedAt']}" if synthesis['completedAt']
207
+ puts " Duration: #{synthesis['duration']}" if synthesis['duration']
208
+ puts " Token Count: #{synthesis['tokenCount']}" if synthesis['tokenCount']
209
+ puts
210
+ end
211
+
212
+ # Execution stats
213
+ execution_count = agent.dig('status', 'executionCount') || 0
214
+ last_execution = agent.dig('status', 'lastExecution')
215
+ next_run = agent.dig('status', 'nextRun')
216
+
217
+ puts 'Execution:'
218
+ puts " Total Runs: #{execution_count}"
219
+ puts " Last Run: #{last_execution || 'Never'}"
220
+ puts " Next Run: #{next_run || 'N/A'}" if agent.dig('spec', 'schedule')
221
+ puts
222
+
223
+ # Conditions
224
+ conditions = agent.dig('status', 'conditions') || []
225
+ if conditions.any?
226
+ puts "Conditions (#{conditions.length}):"
227
+ conditions.each do |condition|
228
+ status_icon = condition['status'] == 'True' ? '✓' : '✗'
229
+ puts " #{status_icon} #{condition['type']}: #{condition['message'] || condition['reason']}"
230
+ end
231
+ puts
232
+ end
233
+
234
+ # Recent events (if available)
235
+ # This would require querying events, which we can add later
236
+ rescue K8s::Error::NotFound
237
+ handle_agent_not_found(name, cluster, k8s, cluster_config)
238
+ rescue StandardError => e
239
+ Formatters::ProgressFormatter.error("Failed to inspect agent: #{e.message}")
240
+ raise if ENV['DEBUG']
241
+
242
+ exit 1
243
+ end
244
+
245
+ desc 'delete NAME', 'Delete an agent'
246
+ option :cluster, type: :string, desc: 'Override current cluster context'
247
+ option :force, type: :boolean, default: false, desc: 'Skip confirmation'
248
+ def delete(name)
249
+ ctx = Helpers::ClusterContext.from_options(options)
250
+
251
+ # Get agent to show details before deletion
252
+ begin
253
+ agent = ctx.client.get_resource('LanguageAgent', name, ctx.namespace)
254
+ rescue K8s::Error::NotFound
255
+ Formatters::ProgressFormatter.error("Agent '#{name}' not found in cluster '#{ctx.name}'")
256
+ exit 1
257
+ end
258
+
259
+ # Confirm deletion using UserPrompts helper
260
+ unless options[:force]
261
+ puts "This will delete agent '#{name}' from cluster '#{ctx.name}':"
262
+ puts " Instructions: #{agent.dig('spec', 'instructions')}"
263
+ puts " Mode: #{agent.dig('spec', 'mode') || 'autonomous'}"
264
+ puts
265
+ return unless Helpers::UserPrompts.confirm('Are you sure?')
266
+ end
267
+
268
+ # Delete the agent
269
+ Formatters::ProgressFormatter.with_spinner("Deleting agent '#{name}'") do
270
+ ctx.client.delete_resource('LanguageAgent', name, ctx.namespace)
271
+ end
272
+
273
+ Formatters::ProgressFormatter.success("Agent '#{name}' deleted successfully")
274
+ rescue StandardError => e
275
+ Formatters::ProgressFormatter.error("Failed to delete agent: #{e.message}")
276
+ raise if ENV['DEBUG']
277
+
278
+ exit 1
279
+ end
280
+
281
+ desc 'logs NAME', 'Show agent execution logs'
282
+ long_desc <<-DESC
283
+ Stream agent execution logs in real-time.
284
+
285
+ Use -f to follow logs continuously (like tail -f).
286
+
287
+ Examples:
288
+ aictl agent logs my-agent
289
+ aictl agent logs my-agent -f
290
+ DESC
291
+ option :cluster, type: :string, desc: 'Override current cluster context'
292
+ option :follow, type: :boolean, aliases: '-f', default: false, desc: 'Follow logs'
293
+ option :tail, type: :numeric, default: 100, desc: 'Number of lines to show from the end'
294
+ def logs(name)
295
+ cluster = Helpers::ClusterValidator.get_cluster(options[:cluster])
296
+ cluster_config = Helpers::ClusterValidator.get_cluster_config(cluster)
297
+
298
+ k8s = Helpers::ClusterValidator.kubernetes_client(options[:cluster])
299
+
300
+ # Get agent to determine the pod name
301
+ begin
302
+ agent = k8s.get_resource('LanguageAgent', name, cluster_config[:namespace])
303
+ rescue K8s::Error::NotFound
304
+ Formatters::ProgressFormatter.error("Agent '#{name}' not found in cluster '#{cluster}'")
305
+ exit 1
306
+ end
307
+
308
+ mode = agent.dig('spec', 'mode') || 'autonomous'
309
+
310
+ # Build kubectl command for log streaming
311
+ kubeconfig_arg = cluster_config[:kubeconfig] ? "--kubeconfig=#{cluster_config[:kubeconfig]}" : ''
312
+ context_arg = cluster_config[:context] ? "--context=#{cluster_config[:context]}" : ''
313
+ namespace_arg = "-n #{cluster_config[:namespace]}"
314
+ tail_arg = "--tail=#{options[:tail]}"
315
+ follow_arg = options[:follow] ? '-f' : ''
316
+
317
+ # For scheduled agents, logs come from CronJob pods
318
+ # For autonomous agents, logs come from Deployment pods
319
+ if mode == 'scheduled'
320
+ # Get most recent job from cronjob
321
+ else
322
+ # Get pod from deployment
323
+ end
324
+ label_selector = "app.kubernetes.io/name=#{name}"
325
+
326
+ # Use kubectl logs with label selector
327
+ cmd = "kubectl #{kubeconfig_arg} #{context_arg} #{namespace_arg} logs -l #{label_selector} #{tail_arg} #{follow_arg} --prefix --all-containers"
328
+
329
+ Formatters::ProgressFormatter.info("Streaming logs for agent '#{name}'...")
330
+ puts
331
+
332
+ # Stream and format logs in real-time
333
+ require 'open3'
334
+ Open3.popen3(cmd) do |_stdin, stdout, stderr, wait_thr|
335
+ # Handle stdout (logs)
336
+ stdout_thread = Thread.new do
337
+ stdout.each_line do |line|
338
+ puts Formatters::LogFormatter.format_line(line.chomp)
339
+ $stdout.flush
340
+ end
341
+ end
342
+
343
+ # Handle stderr (errors)
344
+ stderr_thread = Thread.new do
345
+ stderr.each_line do |line|
346
+ warn line
347
+ end
348
+ end
349
+
350
+ # Wait for both streams to complete
351
+ stdout_thread.join
352
+ stderr_thread.join
353
+
354
+ # Check exit status
355
+ exit_status = wait_thr.value
356
+ exit exit_status.exitstatus unless exit_status.success?
357
+ end
358
+ rescue StandardError => e
359
+ Formatters::ProgressFormatter.error("Failed to get logs: #{e.message}")
360
+ raise if ENV['DEBUG']
361
+
362
+ exit 1
363
+ end
364
+
365
+ desc 'code NAME', 'Display synthesized agent code'
366
+ option :cluster, type: :string, desc: 'Override current cluster context'
367
+ def code(name)
368
+ require_relative '../formatters/code_formatter'
369
+
370
+ cluster = Helpers::ClusterValidator.get_cluster(options[:cluster])
371
+ cluster_config = Helpers::ClusterValidator.get_cluster_config(cluster)
372
+
373
+ k8s = Helpers::ClusterValidator.kubernetes_client(options[:cluster])
374
+
375
+ # Get the code ConfigMap for this agent
376
+ configmap_name = "#{name}-code"
377
+ begin
378
+ configmap = k8s.get_resource('ConfigMap', configmap_name, cluster_config[:namespace])
379
+ rescue K8s::Error::NotFound
380
+ Formatters::ProgressFormatter.error("Synthesized code not found for agent '#{name}'")
381
+ puts
382
+ puts 'Possible reasons:'
383
+ puts ' - Agent synthesis not yet complete'
384
+ puts ' - Agent synthesis failed'
385
+ puts
386
+ puts 'Check synthesis status with:'
387
+ puts " aictl agent inspect #{name}"
388
+ exit 1
389
+ end
390
+
391
+ # Get the agent.rb code from the ConfigMap
392
+ code_content = configmap.dig('data', 'agent.rb')
393
+ unless code_content
394
+ Formatters::ProgressFormatter.error('Code content not found in ConfigMap')
395
+ exit 1
396
+ end
397
+
398
+ # Display with syntax highlighting
399
+ Formatters::CodeFormatter.display_ruby_code(
400
+ code_content,
401
+ title: "Synthesized Code for Agent: #{name}"
402
+ )
403
+
404
+ puts
405
+ puts 'This code was automatically synthesized from the agent instructions.'
406
+ puts "View full agent details with: aictl agent inspect #{name}"
407
+ rescue StandardError => e
408
+ Formatters::ProgressFormatter.error("Failed to get code: #{e.message}")
409
+ raise if ENV['DEBUG']
410
+
411
+ exit 1
412
+ end
413
+
414
+ desc 'edit NAME', 'Edit agent instructions'
415
+ option :cluster, type: :string, desc: 'Override current cluster context'
416
+ def edit(name)
417
+ cluster = Helpers::ClusterValidator.get_cluster(options[:cluster])
418
+ cluster_config = Helpers::ClusterValidator.get_cluster_config(cluster)
419
+
420
+ k8s = Helpers::ClusterValidator.kubernetes_client(options[:cluster])
421
+
422
+ # Get current agent
423
+ begin
424
+ agent = k8s.get_resource('LanguageAgent', name, cluster_config[:namespace])
425
+ rescue K8s::Error::NotFound
426
+ Formatters::ProgressFormatter.error("Agent '#{name}' not found in cluster '#{cluster}'")
427
+ exit 1
428
+ end
429
+
430
+ current_instructions = agent.dig('spec', 'instructions')
431
+
432
+ # Edit instructions in user's editor
433
+ new_instructions = Helpers::EditorHelper.edit_content(
434
+ current_instructions,
435
+ 'agent-instructions-',
436
+ '.txt'
437
+ ).strip
438
+
439
+ # Check if changed
440
+ if new_instructions == current_instructions
441
+ Formatters::ProgressFormatter.info('No changes made')
442
+ return
443
+ end
444
+
445
+ # Update agent resource
446
+ agent['spec']['instructions'] = new_instructions
447
+
448
+ Formatters::ProgressFormatter.with_spinner('Updating agent instructions') do
449
+ k8s.apply_resource(agent)
450
+ end
451
+
452
+ Formatters::ProgressFormatter.success('Agent instructions updated')
453
+ puts
454
+ puts 'The operator will automatically re-synthesize the agent code.'
455
+ puts
456
+ puts 'Watch synthesis progress with:'
457
+ puts " aictl agent inspect #{name}"
458
+ rescue StandardError => e
459
+ Formatters::ProgressFormatter.error("Failed to edit agent: #{e.message}")
460
+ raise if ENV['DEBUG']
461
+
462
+ exit 1
463
+ end
464
+
465
+ desc 'pause NAME', 'Pause scheduled agent execution'
466
+ option :cluster, type: :string, desc: 'Override current cluster context'
467
+ def pause(name)
468
+ cluster = Helpers::ClusterValidator.get_cluster(options[:cluster])
469
+ cluster_config = Helpers::ClusterValidator.get_cluster_config(cluster)
470
+
471
+ k8s = Helpers::ClusterValidator.kubernetes_client(options[:cluster])
472
+
473
+ # Get agent
474
+ begin
475
+ agent = k8s.get_resource('LanguageAgent', name, cluster_config[:namespace])
476
+ rescue K8s::Error::NotFound
477
+ Formatters::ProgressFormatter.error("Agent '#{name}' not found in cluster '#{cluster}'")
478
+ exit 1
479
+ end
480
+
481
+ mode = agent.dig('spec', 'mode') || 'autonomous'
482
+ unless mode == 'scheduled'
483
+ Formatters::ProgressFormatter.warn("Agent '#{name}' is not a scheduled agent (mode: #{mode})")
484
+ puts
485
+ puts 'Only scheduled agents can be paused.'
486
+ puts 'Autonomous agents can be stopped by deleting them.'
487
+ exit 1
488
+ end
489
+
490
+ # Suspend the CronJob by setting spec.suspend = true
491
+ # This is done by patching the underlying CronJob resource
492
+ cronjob_name = name
493
+ namespace = cluster_config[:namespace]
494
+
495
+ Formatters::ProgressFormatter.with_spinner("Pausing agent '#{name}'") do
496
+ # Use kubectl to patch the cronjob
497
+ kubeconfig_arg = cluster_config[:kubeconfig] ? "--kubeconfig=#{cluster_config[:kubeconfig]}" : ''
498
+ context_arg = cluster_config[:context] ? "--context=#{cluster_config[:context]}" : ''
499
+
500
+ cmd = "kubectl #{kubeconfig_arg} #{context_arg} -n #{namespace} patch cronjob #{cronjob_name} -p '{\"spec\":{\"suspend\":true}}'"
501
+ system(cmd)
502
+ end
503
+
504
+ Formatters::ProgressFormatter.success("Agent '#{name}' paused")
505
+ puts
506
+ puts 'The agent will not execute on its schedule until resumed.'
507
+ puts
508
+ puts 'Resume with:'
509
+ puts " aictl agent resume #{name}"
510
+ rescue StandardError => e
511
+ Formatters::ProgressFormatter.error("Failed to pause agent: #{e.message}")
512
+ raise if ENV['DEBUG']
513
+
514
+ exit 1
515
+ end
516
+
517
+ desc 'resume NAME', 'Resume paused agent'
518
+ option :cluster, type: :string, desc: 'Override current cluster context'
519
+ def resume(name)
520
+ cluster = Helpers::ClusterValidator.get_cluster(options[:cluster])
521
+ cluster_config = Helpers::ClusterValidator.get_cluster_config(cluster)
522
+
523
+ k8s = Helpers::ClusterValidator.kubernetes_client(options[:cluster])
524
+
525
+ # Get agent
526
+ begin
527
+ agent = k8s.get_resource('LanguageAgent', name, cluster_config[:namespace])
528
+ rescue K8s::Error::NotFound
529
+ Formatters::ProgressFormatter.error("Agent '#{name}' not found in cluster '#{cluster}'")
530
+ exit 1
531
+ end
532
+
533
+ mode = agent.dig('spec', 'mode') || 'autonomous'
534
+ unless mode == 'scheduled'
535
+ Formatters::ProgressFormatter.warn("Agent '#{name}' is not a scheduled agent (mode: #{mode})")
536
+ puts
537
+ puts 'Only scheduled agents can be resumed.'
538
+ exit 1
539
+ end
540
+
541
+ # Resume the CronJob by setting spec.suspend = false
542
+ cronjob_name = name
543
+ namespace = cluster_config[:namespace]
544
+
545
+ Formatters::ProgressFormatter.with_spinner("Resuming agent '#{name}'") do
546
+ # Use kubectl to patch the cronjob
547
+ kubeconfig_arg = cluster_config[:kubeconfig] ? "--kubeconfig=#{cluster_config[:kubeconfig]}" : ''
548
+ context_arg = cluster_config[:context] ? "--context=#{cluster_config[:context]}" : ''
549
+
550
+ cmd = "kubectl #{kubeconfig_arg} #{context_arg} -n #{namespace} patch cronjob #{cronjob_name} -p '{\"spec\":{\"suspend\":false}}'"
551
+ system(cmd)
552
+ end
553
+
554
+ Formatters::ProgressFormatter.success("Agent '#{name}' resumed")
555
+ puts
556
+ puts 'The agent will now execute according to its schedule.'
557
+ puts
558
+ puts 'View next execution time with:'
559
+ puts " aictl agent inspect #{name}"
560
+ rescue StandardError => e
561
+ Formatters::ProgressFormatter.error("Failed to resume agent: #{e.message}")
562
+ raise if ENV['DEBUG']
563
+
564
+ exit 1
565
+ end
566
+
567
+ desc 'workspace NAME', 'Browse agent workspace files'
568
+ long_desc <<-DESC
569
+ Browse and manage the workspace files for an agent.
570
+
571
+ Workspaces provide persistent storage for agents to maintain state,
572
+ cache data, and remember information across executions.
573
+
574
+ Examples:
575
+ aictl agent workspace my-agent # List all files
576
+ aictl agent workspace my-agent --path /workspace/state.json # View specific file
577
+ aictl agent workspace my-agent --clean # Clear workspace
578
+ DESC
579
+ option :cluster, type: :string, desc: 'Override current cluster context'
580
+ option :path, type: :string, desc: 'View specific file contents'
581
+ option :clean, type: :boolean, desc: 'Clear workspace (with confirmation)'
582
+ def workspace(name)
583
+ ctx = Helpers::ClusterContext.from_options(options)
584
+
585
+ # Get agent to verify it exists
586
+ begin
587
+ agent = ctx.client.get_resource('LanguageAgent', name, ctx.namespace)
588
+ rescue K8s::Error::NotFound
589
+ Formatters::ProgressFormatter.error("Agent '#{name}' not found in cluster '#{ctx.name}'")
590
+ exit 1
591
+ end
592
+
593
+ # Check if workspace is enabled
594
+ workspace_enabled = agent.dig('spec', 'workspace', 'enabled')
595
+ unless workspace_enabled
596
+ Formatters::ProgressFormatter.warn("Workspace is not enabled for agent '#{name}'")
597
+ puts
598
+ puts 'Enable workspace in agent configuration:'
599
+ puts ' spec:'
600
+ puts ' workspace:'
601
+ puts ' enabled: true'
602
+ puts ' size: 10Gi'
603
+ exit 1
604
+ end
605
+
606
+ if options[:path]
607
+ view_workspace_file(ctx, name, options[:path])
608
+ elsif options[:clean]
609
+ clean_workspace(ctx, name)
610
+ else
611
+ list_workspace_files(ctx, name)
612
+ end
613
+ rescue StandardError => e
614
+ Formatters::ProgressFormatter.error("Failed to access workspace: #{e.message}")
615
+ raise if ENV['DEBUG']
616
+
617
+ exit 1
618
+ end
619
+
620
+ private
621
+
622
+ def handle_agent_not_found(name, cluster, k8s, cluster_config)
623
+ # Get available agents for fuzzy matching
624
+ agents = k8s.list_resources('LanguageAgent', namespace: cluster_config[:namespace])
625
+ available_names = agents.map { |a| a.dig('metadata', 'name') }
626
+
627
+ error = K8s::Error::NotFound.new(404, 'Not Found', 'LanguageAgent')
628
+ Errors::Handler.handle_not_found(error,
629
+ resource_type: 'LanguageAgent',
630
+ resource_name: name,
631
+ cluster: cluster,
632
+ available_resources: available_names)
633
+ end
634
+
635
+ def display_agent_created(agent, cluster, _description, synthesis_result)
636
+ require 'pastel'
637
+ require_relative '../formatters/code_formatter'
638
+
639
+ pastel = Pastel.new
640
+ agent_name = agent.dig('metadata', 'name')
641
+
642
+ puts
643
+ Formatters::ProgressFormatter.success("Agent '#{agent_name}' created and deployed!")
644
+ puts
645
+
646
+ # Get synthesized code if available
647
+ begin
648
+ cluster_config = Helpers::ClusterValidator.get_cluster_config(cluster)
649
+ k8s = Helpers::ClusterValidator.kubernetes_client(cluster)
650
+ configmap_name = "#{agent_name}-code"
651
+ configmap = k8s.get_resource('ConfigMap', configmap_name, cluster_config[:namespace])
652
+ code_content = configmap.dig('data', 'agent.rb')
653
+
654
+ if code_content
655
+ # Display code preview (first 20 lines)
656
+ Formatters::CodeFormatter.display_ruby_code(
657
+ code_content,
658
+ title: 'Synthesized Code Preview:',
659
+ max_lines: 20
660
+ )
661
+ puts
662
+ end
663
+ rescue StandardError
664
+ # Code not available yet, skip preview
665
+ end
666
+
667
+ # Display agent configuration
668
+ puts pastel.cyan('Agent Configuration:')
669
+ puts " Name: #{agent_name}"
670
+ puts " Cluster: #{cluster}"
671
+
672
+ # Schedule information
673
+ schedule = agent.dig('spec', 'schedule')
674
+ mode = agent.dig('spec', 'mode') || 'autonomous'
675
+ if schedule
676
+ human_schedule = parse_schedule(schedule)
677
+ puts " Schedule: #{human_schedule} (#{schedule})"
678
+
679
+ # Calculate next run
680
+ next_run = agent.dig('status', 'nextRun')
681
+ if next_run
682
+ begin
683
+ next_run_time = Time.parse(next_run)
684
+ time_until = format_time_until(next_run_time)
685
+ puts " Next run: #{next_run} (#{time_until})"
686
+ rescue StandardError
687
+ puts " Next run: #{next_run}"
688
+ end
689
+ end
690
+ else
691
+ puts " Mode: #{mode}"
692
+ end
693
+
694
+ # Persona
695
+ persona = agent.dig('spec', 'persona')
696
+ puts " Persona: #{persona || '(auto-selected)'}"
697
+
698
+ # Tools
699
+ tools = agent.dig('spec', 'tools') || []
700
+ puts " Tools: #{tools.join(', ')}" if tools.any?
701
+
702
+ # Models
703
+ model_refs = agent.dig('spec', 'modelRefs') || []
704
+ if model_refs.any?
705
+ model_names = model_refs.map { |ref| ref['name'] }
706
+ puts " Models: #{model_names.join(', ')}"
707
+ end
708
+
709
+ puts
710
+
711
+ # Synthesis stats
712
+ if synthesis_result[:duration]
713
+ puts pastel.dim("Synthesis completed in #{format_duration(synthesis_result[:duration])}")
714
+ puts pastel.dim("Model: #{synthesis_result[:model]}") if synthesis_result[:model]
715
+ puts
716
+ end
717
+
718
+ # Next steps
719
+ puts pastel.cyan('Next Steps:')
720
+ puts " aictl agent logs #{agent_name} -f # Follow agent execution logs"
721
+ puts " aictl agent code #{agent_name} # View full synthesized code"
722
+ puts " aictl agent inspect #{agent_name} # View detailed agent status"
723
+ puts
724
+ end
725
+
726
+ def parse_schedule(cron_expr)
727
+ # Simple cron to human-readable conversion
728
+ # Format: minute hour day month weekday
729
+ parts = cron_expr.split
730
+
731
+ return cron_expr if parts.length != 5
732
+
733
+ minute, hour, day, month, weekday = parts
734
+
735
+ # Common patterns
736
+ if minute == '0' && hour != '*' && day == '*' && month == '*' && weekday == '*'
737
+ # Daily at specific hour
738
+ hour12 = hour.to_i % 12
739
+ hour12 = 12 if hour12.zero?
740
+ period = hour.to_i < 12 ? 'AM' : 'PM'
741
+ return "Daily at #{hour12}:00 #{period}"
742
+ elsif minute != '*' && hour != '*' && day == '*' && month == '*' && weekday == '*'
743
+ # Daily at specific time
744
+ hour12 = hour.to_i % 12
745
+ hour12 = 12 if hour12.zero?
746
+ period = hour.to_i < 12 ? 'AM' : 'PM'
747
+ return "Daily at #{hour12}:#{minute.rjust(2, '0')} #{period}"
748
+ elsif minute.start_with?('*/') && hour == '*'
749
+ # Every N minutes
750
+ interval = minute[2..].to_i
751
+ return "Every #{interval} minutes"
752
+ elsif minute == '*' && hour.start_with?('*/')
753
+ # Every N hours
754
+ interval = hour[2..].to_i
755
+ return "Every #{interval} hours"
756
+ end
757
+
758
+ # Fallback to cron expression
759
+ cron_expr
760
+ end
761
+
762
+ def format_time_until(future_time)
763
+ Formatters::ValueFormatter.time_until(future_time)
764
+ end
765
+
766
+ def display_dry_run_preview(agent_resource, cluster, description)
767
+ require 'yaml'
768
+
769
+ puts
770
+ puts '=' * 80
771
+ puts ' DRY RUN: Agent Creation Preview'
772
+ puts '=' * 80
773
+ puts
774
+
775
+ # Extract key information
776
+ name = agent_resource.dig('metadata', 'name')
777
+ namespace = agent_resource.dig('metadata', 'namespace')
778
+ persona = agent_resource.dig('spec', 'persona')
779
+ tools = agent_resource.dig('spec', 'tools') || []
780
+ model_refs = agent_resource.dig('spec', 'modelRefs') || []
781
+ models = model_refs.map { |ref| ref['name'] }
782
+ mode = agent_resource.dig('spec', 'mode') || 'autonomous'
783
+ schedule = agent_resource.dig('spec', 'schedule')
784
+
785
+ # Display summary
786
+ puts 'Agent Summary:'
787
+ puts " Name: #{name}"
788
+ puts " Cluster: #{cluster}"
789
+ puts " Namespace: #{namespace}"
790
+ puts " Mode: #{mode}"
791
+ puts " Schedule: #{schedule || 'N/A'}" if schedule
792
+ puts " Instructions: #{description}"
793
+ puts
794
+
795
+ # Show detected configuration
796
+ if persona
797
+ puts 'Detected Configuration:'
798
+ puts " Persona: #{persona}"
799
+ end
800
+
801
+ puts " Tools: #{tools.join(', ')}" if tools.any?
802
+
803
+ puts " Models: #{models.join(', ')}" if models.any?
804
+
805
+ puts if persona || tools.any? || models.any?
806
+
807
+ # Show full YAML
808
+ puts 'Generated YAML:'
809
+ puts '─' * 80
810
+ puts YAML.dump(agent_resource)
811
+ puts '─' * 80
812
+ puts
813
+
814
+ # Show what would happen
815
+ puts 'What would happen:'
816
+ puts ' 1. Agent resource would be created in the cluster'
817
+ puts ' 2. Operator would synthesize Ruby code from instructions'
818
+ puts ' 3. Agent would be deployed and start running'
819
+ puts
820
+
821
+ # Show how to actually create
822
+ Formatters::ProgressFormatter.info('No changes made (dry-run mode)')
823
+ puts
824
+ puts 'To create this agent for real, run:'
825
+ cmd_parts = ["aictl agent create \"#{description}\""]
826
+ cmd_parts << "--name #{name}" if options[:name]
827
+ cmd_parts << "--persona #{persona}" if persona
828
+ cmd_parts << "--tools #{tools.join(' ')}" if tools.any?
829
+ cmd_parts << "--models #{models.join(' ')}" if models.any?
830
+ cmd_parts << "--cluster #{cluster}" if options[:cluster]
831
+ puts " #{cmd_parts.join(' ')}"
832
+ end
833
+
834
+ def format_status(status)
835
+ require 'pastel'
836
+ pastel = Pastel.new
837
+
838
+ case status.downcase
839
+ when 'ready', 'running', 'active'
840
+ "#{pastel.green('●')} #{status}"
841
+ when 'pending', 'creating', 'synthesizing'
842
+ "#{pastel.yellow('●')} #{status}"
843
+ when 'failed', 'error'
844
+ "#{pastel.red('●')} #{status}"
845
+ when 'paused', 'stopped'
846
+ "#{pastel.dim('●')} #{status}"
847
+ else
848
+ "#{pastel.dim('●')} #{status}"
849
+ end
850
+ end
851
+
852
+ def generate_agent_name(description)
853
+ # Simple name generation from description
854
+ # Take first few words, lowercase, hyphenate
855
+ words = description.downcase.gsub(/[^a-z0-9\s]/, '').split[0..2]
856
+ name = words.join('-')
857
+ # Add random suffix to avoid collisions
858
+ "#{name}-#{Time.now.to_i.to_s[-4..]}"
859
+ end
860
+
861
+ def watch_synthesis_status(k8s, agent_name, namespace)
862
+ # Start with analyzing description
863
+ puts
864
+ Formatters::ProgressFormatter.info('Synthesizing agent code...')
865
+ puts
866
+
867
+ max_wait = 600 # Wait up to 10 minutes (local models can be slow)
868
+ interval = 2 # Check every 2 seconds
869
+ elapsed = 0
870
+ start_time = Time.now
871
+ synthesis_data = {}
872
+
873
+ result = Formatters::ProgressFormatter.with_spinner('Analyzing description and generating code') do
874
+ loop do
875
+ status = check_synthesis_status(k8s, agent_name, namespace, synthesis_data, start_time)
876
+ return status if status
877
+
878
+ # Timeout check
879
+ if elapsed >= max_wait
880
+ Formatters::ProgressFormatter.warn('Synthesis taking longer than expected, continuing in background...')
881
+ puts
882
+ puts 'Check synthesis status with:'
883
+ puts " aictl agent inspect #{agent_name}"
884
+ return { success: true, timeout: true }
885
+ end
886
+
887
+ sleep interval
888
+ elapsed += interval
889
+ end
890
+ rescue K8s::Error::NotFound
891
+ # Agent not found yet, keep waiting
892
+ sleep interval
893
+ elapsed += interval
894
+ retry if elapsed < max_wait
895
+
896
+ Formatters::ProgressFormatter.error('Agent resource not found')
897
+ return { success: false }
898
+ rescue StandardError => e
899
+ Formatters::ProgressFormatter.warn("Could not watch synthesis: #{e.message}")
900
+ return { success: true } # Continue anyway
901
+ end
902
+
903
+ # Show synthesis details after spinner completes
904
+ if result[:success] && !result[:timeout]
905
+ duration = result[:duration]
906
+ Formatters::ProgressFormatter.success("Code synthesis completed in #{format_duration(duration)}")
907
+ puts " Model: #{synthesis_data[:model]}" if synthesis_data[:model]
908
+ puts " Tokens: #{synthesis_data[:token_count]}" if synthesis_data[:token_count]
909
+ end
910
+
911
+ result
912
+ end
913
+
914
+ def check_synthesis_status(k8s, agent_name, namespace, synthesis_data, start_time)
915
+ agent = k8s.get_resource('LanguageAgent', agent_name, namespace)
916
+ conditions = agent.dig('status', 'conditions') || []
917
+ synthesis_status = agent.dig('status', 'synthesis')
918
+
919
+ # Capture synthesis metadata
920
+ if synthesis_status
921
+ synthesis_data[:model] = synthesis_status['model']
922
+ synthesis_data[:token_count] = synthesis_status['tokenCount']
923
+ end
924
+
925
+ # Check for synthesis completion
926
+ synthesized = conditions.find { |c| c['type'] == 'Synthesized' }
927
+ return nil unless synthesized
928
+
929
+ if synthesized['status'] == 'True'
930
+ duration = Time.now - start_time
931
+ { success: true, duration: duration, **synthesis_data }
932
+ elsif synthesized['status'] == 'False'
933
+ Formatters::ProgressFormatter.error("Synthesis failed: #{synthesized['message']}")
934
+ { success: false }
935
+ end
936
+ end
937
+
938
+ def format_duration(seconds)
939
+ Formatters::ValueFormatter.duration(seconds)
940
+ end
941
+
942
+ def list_cluster_agents(cluster)
943
+ cluster_config = Helpers::ClusterValidator.get_cluster_config(cluster)
944
+
945
+ Formatters::ProgressFormatter.info("Agents in cluster '#{cluster}'")
946
+
947
+ k8s = Helpers::ClusterValidator.kubernetes_client(cluster)
948
+
949
+ agents = k8s.list_resources('LanguageAgent', namespace: cluster_config[:namespace])
950
+
951
+ table_data = agents.map do |agent|
952
+ {
953
+ name: agent.dig('metadata', 'name'),
954
+ mode: agent.dig('spec', 'mode') || 'autonomous',
955
+ status: agent.dig('status', 'phase') || 'Unknown',
956
+ next_run: agent.dig('status', 'nextRun') || 'N/A',
957
+ executions: agent.dig('status', 'executionCount') || 0
958
+ }
959
+ end
960
+
961
+ Formatters::TableFormatter.agents(table_data)
962
+
963
+ return unless agents.empty?
964
+
965
+ puts
966
+ puts 'Create an agent with:'
967
+ puts ' aictl agent create "<description>"'
968
+ end
969
+
970
+ def list_all_clusters
971
+ clusters = Config::ClusterConfig.list_clusters
972
+
973
+ if clusters.empty?
974
+ Formatters::ProgressFormatter.info('No clusters found')
975
+ puts
976
+ puts 'Create a cluster first:'
977
+ puts ' aictl cluster create <name>'
978
+ return
979
+ end
980
+
981
+ all_agents = []
982
+
983
+ clusters.each do |cluster|
984
+ k8s = Helpers::ClusterValidator.kubernetes_client(cluster[:name])
985
+
986
+ agents = k8s.list_resources('LanguageAgent', namespace: cluster[:namespace])
987
+
988
+ agents.each do |agent|
989
+ all_agents << {
990
+ cluster: cluster[:name],
991
+ name: agent.dig('metadata', 'name'),
992
+ mode: agent.dig('spec', 'mode') || 'autonomous',
993
+ status: agent.dig('status', 'phase') || 'Unknown',
994
+ next_run: agent.dig('status', 'nextRun') || 'N/A',
995
+ executions: agent.dig('status', 'executionCount') || 0
996
+ }
997
+ end
998
+ rescue StandardError => e
999
+ Formatters::ProgressFormatter.warn("Failed to get agents from cluster '#{cluster[:name]}': #{e.message}")
1000
+ end
1001
+
1002
+ # Group agents by cluster for formatted display
1003
+ agents_by_cluster = all_agents.group_by { |agent| agent[:cluster] }
1004
+ .transform_values { |agents| agents.map { |a| a.except(:cluster) } }
1005
+
1006
+ Formatters::TableFormatter.all_agents(agents_by_cluster)
1007
+ end
1008
+
1009
+ # Workspace-related helper methods
1010
+
1011
+ def get_agent_pod(ctx, agent_name)
1012
+ # Find pod for this agent using label selector
1013
+ label_selector = "app.kubernetes.io/name=#{agent_name}"
1014
+ pods = ctx.client.list_resources('Pod', namespace: ctx.namespace, label_selector: label_selector)
1015
+
1016
+ if pods.empty?
1017
+ Formatters::ProgressFormatter.error("No running pods found for agent '#{agent_name}'")
1018
+ puts
1019
+ puts 'Possible reasons:'
1020
+ puts ' - Agent pod has not started yet'
1021
+ puts ' - Agent is paused or stopped'
1022
+ puts ' - Agent failed to deploy'
1023
+ puts
1024
+ puts 'Check agent status with:'
1025
+ puts " aictl agent inspect #{agent_name}"
1026
+ exit 1
1027
+ end
1028
+
1029
+ # Find a running pod
1030
+ running_pod = pods.find do |pod|
1031
+ pod.dig('status', 'phase') == 'Running'
1032
+ end
1033
+
1034
+ unless running_pod
1035
+ Formatters::ProgressFormatter.error('Agent pod exists but is not running')
1036
+ puts
1037
+ puts "Current pod status: #{pods.first.dig('status', 'phase')}"
1038
+ puts
1039
+ puts 'Check pod logs with:'
1040
+ puts " aictl agent logs #{agent_name}"
1041
+ exit 1
1042
+ end
1043
+
1044
+ running_pod.dig('metadata', 'name')
1045
+ end
1046
+
1047
+ def exec_in_pod(ctx, pod_name, command)
1048
+ # Properly escape command for shell
1049
+ cmd_str = command.is_a?(Array) ? command.join(' ') : command
1050
+ kubectl_cmd = "#{ctx.kubectl_prefix} exec #{pod_name} -- #{cmd_str}"
1051
+
1052
+ # Execute and capture output
1053
+ require 'open3'
1054
+ stdout, stderr, status = Open3.capture3(kubectl_cmd)
1055
+
1056
+ raise "Command failed: #{stderr}" unless status.success?
1057
+
1058
+ stdout
1059
+ end
1060
+
1061
+ def list_workspace_files(ctx, agent_name)
1062
+ require 'pastel'
1063
+ pastel = Pastel.new
1064
+
1065
+ pod_name = get_agent_pod(ctx, agent_name)
1066
+
1067
+ # Check if workspace directory exists
1068
+ begin
1069
+ exec_in_pod(ctx, pod_name, 'test -d /workspace')
1070
+ rescue StandardError
1071
+ Formatters::ProgressFormatter.error('Workspace directory not found in agent pod')
1072
+ puts
1073
+ puts 'The /workspace directory does not exist in the agent pod.'
1074
+ puts 'This agent may not have workspace support enabled.'
1075
+ exit 1
1076
+ end
1077
+
1078
+ # Get workspace usage
1079
+ usage_output = exec_in_pod(
1080
+ ctx,
1081
+ pod_name,
1082
+ 'du -sh /workspace 2>/dev/null || echo "0\t/workspace"'
1083
+ )
1084
+ workspace_size = usage_output.split("\t").first.strip
1085
+
1086
+ # List files with details
1087
+ file_list = exec_in_pod(
1088
+ ctx,
1089
+ pod_name,
1090
+ 'find /workspace -ls 2>/dev/null | tail -n +2'
1091
+ )
1092
+
1093
+ puts
1094
+ puts pastel.cyan("Workspace for agent '#{agent_name}' (#{workspace_size})")
1095
+ puts '=' * 60
1096
+ puts
1097
+
1098
+ if file_list.strip.empty?
1099
+ puts pastel.dim('Workspace is empty')
1100
+ puts
1101
+ puts 'The agent will create files here as it runs.'
1102
+ puts
1103
+ return
1104
+ end
1105
+
1106
+ # Parse and display file list
1107
+ file_list.each_line do |line|
1108
+ parts = line.strip.split(/\s+/, 11)
1109
+ next if parts.length < 11
1110
+
1111
+ # Extract relevant parts
1112
+ # Format: inode blocks perms links user group size month day time path
1113
+ perms = parts[2]
1114
+ size = parts[6]
1115
+ month = parts[7]
1116
+ day = parts[8]
1117
+ time_or_year = parts[9]
1118
+ path = parts[10]
1119
+
1120
+ # Skip the /workspace directory itself
1121
+ next if path == '/workspace'
1122
+
1123
+ # Determine file type and icon
1124
+ icon = if perms.start_with?('d')
1125
+ pastel.blue('📁')
1126
+ else
1127
+ pastel.white('📄')
1128
+ end
1129
+
1130
+ # Format path relative to workspace
1131
+ relative_path = path.sub('/workspace/', '')
1132
+ indent = ' ' * relative_path.count('/')
1133
+
1134
+ # Format size
1135
+ formatted_size = format_file_size(size.to_i).rjust(8)
1136
+
1137
+ # Format time
1138
+ formatted_time = "#{month} #{day.rjust(2)} #{time_or_year}"
1139
+
1140
+ puts "#{indent}#{icon} #{File.basename(relative_path).ljust(30)} #{pastel.dim(formatted_size)} #{pastel.dim(formatted_time)}"
1141
+ end
1142
+
1143
+ puts
1144
+ puts pastel.dim('Commands:')
1145
+ puts pastel.dim(" aictl agent workspace #{agent_name} --path /workspace/<file> # View file")
1146
+ puts pastel.dim(" aictl agent workspace #{agent_name} --clean # Clear workspace")
1147
+ puts
1148
+ end
1149
+
1150
+ def view_workspace_file(ctx, agent_name, file_path)
1151
+ require 'pastel'
1152
+ pastel = Pastel.new
1153
+
1154
+ pod_name = get_agent_pod(ctx, agent_name)
1155
+
1156
+ # Check if file exists
1157
+ begin
1158
+ exec_in_pod(ctx, pod_name, "test -f #{file_path}")
1159
+ rescue StandardError
1160
+ Formatters::ProgressFormatter.error("File not found: #{file_path}")
1161
+ puts
1162
+ puts 'List available files with:'
1163
+ puts " aictl agent workspace #{agent_name}"
1164
+ exit 1
1165
+ end
1166
+
1167
+ # Get file metadata
1168
+ stat_output = exec_in_pod(
1169
+ ctx,
1170
+ pod_name,
1171
+ "stat -c '%s %Y' #{file_path}"
1172
+ )
1173
+ size, mtime = stat_output.strip.split
1174
+
1175
+ # Get file contents
1176
+ contents = exec_in_pod(
1177
+ ctx,
1178
+ pod_name,
1179
+ "cat #{file_path}"
1180
+ )
1181
+
1182
+ # Display file
1183
+ puts
1184
+ puts pastel.cyan("File: #{file_path}")
1185
+ puts "Size: #{format_file_size(size.to_i)}"
1186
+ puts "Modified: #{format_timestamp(Time.at(mtime.to_i))}"
1187
+ puts '=' * 60
1188
+ puts
1189
+ puts contents
1190
+ puts
1191
+ end
1192
+
1193
+ def clean_workspace(ctx, agent_name)
1194
+ require 'pastel'
1195
+ pastel = Pastel.new
1196
+
1197
+ pod_name = get_agent_pod(ctx, agent_name)
1198
+
1199
+ # Get current workspace usage
1200
+ usage_output = exec_in_pod(
1201
+ ctx,
1202
+ pod_name,
1203
+ 'du -sh /workspace 2>/dev/null || echo "0\t/workspace"'
1204
+ )
1205
+ workspace_size = usage_output.split("\t").first.strip
1206
+
1207
+ # Count files
1208
+ file_count = exec_in_pod(
1209
+ ctx,
1210
+ pod_name,
1211
+ 'find /workspace -type f | wc -l'
1212
+ ).strip.to_i
1213
+
1214
+ puts
1215
+ puts pastel.yellow("This will delete ALL files in the workspace for '#{agent_name}'")
1216
+ puts
1217
+ puts 'The agent will lose:'
1218
+ puts ' • Execution history'
1219
+ puts ' • Cached data'
1220
+ puts ' • State information'
1221
+ puts
1222
+ puts "Current workspace: #{file_count} files, #{workspace_size}"
1223
+ puts
1224
+
1225
+ # Use UserPrompts helper
1226
+ return unless Helpers::UserPrompts.confirm('Are you sure?')
1227
+
1228
+ # Delete all files in workspace
1229
+ Formatters::ProgressFormatter.with_spinner('Cleaning workspace') do
1230
+ exec_in_pod(
1231
+ ctx,
1232
+ pod_name,
1233
+ 'find /workspace -mindepth 1 -delete'
1234
+ )
1235
+ end
1236
+
1237
+ Formatters::ProgressFormatter.success("Workspace cleared (freed #{workspace_size})")
1238
+ puts
1239
+ puts 'The agent will start fresh on its next execution.'
1240
+ end
1241
+
1242
+ def format_file_size(bytes)
1243
+ Formatters::ValueFormatter.file_size(bytes)
1244
+ end
1245
+
1246
+ def format_timestamp(time)
1247
+ Formatters::ValueFormatter.timestamp(time)
1248
+ end
1249
+ end
1250
+ end
1251
+ end
1252
+ end