language-operator 0.1.30 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +7 -8
  3. data/CHANGELOG.md +49 -0
  4. data/CI_STATUS.md +56 -0
  5. data/Gemfile.lock +2 -2
  6. data/Makefile +28 -7
  7. data/Rakefile +29 -0
  8. data/docs/dsl/SCHEMA_VERSION.md +250 -0
  9. data/docs/dsl/agent-reference.md +13 -0
  10. data/lib/language_operator/agent/base.rb +10 -6
  11. data/lib/language_operator/agent/executor.rb +19 -97
  12. data/lib/language_operator/agent/safety/ast_validator.rb +62 -43
  13. data/lib/language_operator/agent/safety/safe_executor.rb +39 -2
  14. data/lib/language_operator/agent/scheduler.rb +60 -0
  15. data/lib/language_operator/agent/task_executor.rb +548 -0
  16. data/lib/language_operator/agent.rb +90 -27
  17. data/lib/language_operator/cli/base_command.rb +117 -0
  18. data/lib/language_operator/cli/commands/agent.rb +351 -466
  19. data/lib/language_operator/cli/commands/cluster.rb +276 -256
  20. data/lib/language_operator/cli/commands/install.rb +110 -119
  21. data/lib/language_operator/cli/commands/model.rb +284 -184
  22. data/lib/language_operator/cli/commands/persona.rb +220 -289
  23. data/lib/language_operator/cli/commands/quickstart.rb +4 -5
  24. data/lib/language_operator/cli/commands/status.rb +36 -53
  25. data/lib/language_operator/cli/commands/system.rb +760 -0
  26. data/lib/language_operator/cli/commands/tool.rb +356 -422
  27. data/lib/language_operator/cli/commands/use.rb +19 -22
  28. data/lib/language_operator/cli/formatters/code_formatter.rb +3 -7
  29. data/lib/language_operator/cli/formatters/log_formatter.rb +3 -5
  30. data/lib/language_operator/cli/formatters/progress_formatter.rb +3 -7
  31. data/lib/language_operator/cli/formatters/status_formatter.rb +37 -0
  32. data/lib/language_operator/cli/formatters/table_formatter.rb +10 -26
  33. data/lib/language_operator/cli/helpers/pastel_helper.rb +24 -0
  34. data/lib/language_operator/cli/helpers/resource_dependency_checker.rb +0 -18
  35. data/lib/language_operator/cli/main.rb +4 -0
  36. data/lib/language_operator/cli/wizards/quickstart_wizard.rb +0 -1
  37. data/lib/language_operator/client/config.rb +20 -21
  38. data/lib/language_operator/config.rb +115 -3
  39. data/lib/language_operator/constants.rb +54 -0
  40. data/lib/language_operator/dsl/agent_context.rb +7 -7
  41. data/lib/language_operator/dsl/agent_definition.rb +111 -26
  42. data/lib/language_operator/dsl/config.rb +30 -66
  43. data/lib/language_operator/dsl/main_definition.rb +114 -0
  44. data/lib/language_operator/dsl/schema.rb +1143 -0
  45. data/lib/language_operator/dsl/task_definition.rb +315 -0
  46. data/lib/language_operator/dsl.rb +1 -1
  47. data/lib/language_operator/instrumentation/task_tracer.rb +285 -0
  48. data/lib/language_operator/logger.rb +4 -4
  49. data/lib/language_operator/synthesis_test_harness.rb +324 -0
  50. data/lib/language_operator/templates/README.md +23 -0
  51. data/lib/language_operator/templates/examples/agent_synthesis.tmpl +133 -0
  52. data/lib/language_operator/templates/examples/persona_distillation.tmpl +19 -0
  53. data/lib/language_operator/templates/schema/.gitkeep +0 -0
  54. data/lib/language_operator/templates/schema/CHANGELOG.md +119 -0
  55. data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +306 -0
  56. data/lib/language_operator/templates/schema/agent_dsl_schema.json +494 -0
  57. data/lib/language_operator/type_coercion.rb +250 -0
  58. data/lib/language_operator/ux/base.rb +81 -0
  59. data/lib/language_operator/ux/concerns/README.md +155 -0
  60. data/lib/language_operator/ux/concerns/headings.rb +90 -0
  61. data/lib/language_operator/ux/concerns/input_validation.rb +146 -0
  62. data/lib/language_operator/ux/concerns/provider_helpers.rb +167 -0
  63. data/lib/language_operator/ux/create_agent.rb +252 -0
  64. data/lib/language_operator/ux/create_model.rb +267 -0
  65. data/lib/language_operator/ux/quickstart.rb +594 -0
  66. data/lib/language_operator/version.rb +1 -1
  67. data/lib/language_operator.rb +2 -0
  68. data/requirements/ARCHITECTURE.md +1 -0
  69. data/requirements/SCRATCH.md +153 -0
  70. data/requirements/dsl.md +0 -0
  71. data/requirements/features +1 -0
  72. data/requirements/personas +1 -0
  73. data/requirements/proposals +1 -0
  74. data/requirements/tasks/iterate.md +14 -15
  75. data/requirements/tasks/optimize.md +13 -4
  76. data/synth/001/Makefile +90 -0
  77. data/synth/001/agent.rb +26 -0
  78. data/synth/001/agent.yaml +7 -0
  79. data/synth/001/output.log +44 -0
  80. data/synth/Makefile +39 -0
  81. data/synth/README.md +342 -0
  82. metadata +49 -18
  83. data/examples/README.md +0 -569
  84. data/examples/agent_example.rb +0 -86
  85. data/examples/chat_endpoint_agent.rb +0 -118
  86. data/examples/github_webhook_agent.rb +0 -171
  87. data/examples/mcp_agent.rb +0 -158
  88. data/examples/oauth_callback_agent.rb +0 -296
  89. data/examples/stripe_webhook_agent.rb +0 -219
  90. data/examples/webhook_agent.rb +0 -80
  91. data/lib/language_operator/dsl/workflow_definition.rb +0 -259
  92. data/test_agent_dsl.rb +0 -108
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'thor'
3
+ require_relative '../base_command'
4
4
  require 'pastel'
5
5
  require_relative '../formatters/progress_formatter'
6
6
  require_relative '../../config/cluster_config'
@@ -9,37 +9,34 @@ module LanguageOperator
9
9
  module CLI
10
10
  module Commands
11
11
  # Switch cluster context command
12
- class Use < Thor
12
+ class Use < BaseCommand
13
13
  desc 'use CLUSTER', 'Switch to a different cluster context'
14
14
  def self.exit_on_failure?
15
15
  true
16
16
  end
17
17
 
18
18
  def switch(cluster_name)
19
- unless Config::ClusterConfig.cluster_exists?(cluster_name)
20
- Formatters::ProgressFormatter.error("Cluster '#{cluster_name}' not found")
21
- puts "\nAvailable clusters:"
22
- Config::ClusterConfig.list_clusters.each do |cluster|
23
- puts " - #{cluster[:name]}"
19
+ handle_command_error('switch cluster') do
20
+ unless Config::ClusterConfig.cluster_exists?(cluster_name)
21
+ Formatters::ProgressFormatter.error("Cluster '#{cluster_name}' not found")
22
+ puts "\nAvailable clusters:"
23
+ Config::ClusterConfig.list_clusters.each do |cluster|
24
+ puts " - #{cluster[:name]}"
25
+ end
26
+ exit 1
24
27
  end
25
- exit 1
26
- end
27
-
28
- Config::ClusterConfig.set_current_cluster(cluster_name)
29
- cluster = Config::ClusterConfig.get_cluster(cluster_name)
30
28
 
31
- Formatters::ProgressFormatter.success("Switched to cluster '#{cluster_name}'")
29
+ Config::ClusterConfig.set_current_cluster(cluster_name)
30
+ cluster = Config::ClusterConfig.get_cluster(cluster_name)
32
31
 
33
- pastel = Pastel.new
34
- puts "\nCluster Details"
35
- puts '----------------'
36
- puts "Name: #{pastel.bold.white(cluster[:name])}"
37
- puts "Namespace: #{pastel.bold.white(cluster[:namespace])}"
38
- rescue StandardError => e
39
- Formatters::ProgressFormatter.error("Failed to switch cluster: #{e.message}")
40
- raise if ENV['DEBUG']
32
+ Formatters::ProgressFormatter.success("Switched to cluster '#{cluster_name}'")
41
33
 
42
- exit 1
34
+ pastel = Pastel.new
35
+ puts "\nCluster Details"
36
+ puts '----------------'
37
+ puts "Name: #{pastel.bold.white(cluster[:name])}"
38
+ puts "Namespace: #{pastel.bold.white(cluster[:namespace])}"
39
+ end
43
40
  end
44
41
  end
45
42
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rouge'
4
- require 'pastel'
4
+ require_relative '../helpers/pastel_helper'
5
5
 
6
6
  module LanguageOperator
7
7
  module CLI
@@ -9,6 +9,8 @@ module LanguageOperator
9
9
  # Formatter for displaying syntax-highlighted code in the terminal
10
10
  class CodeFormatter
11
11
  class << self
12
+ include Helpers::PastelHelper
13
+
12
14
  # Display Ruby code with syntax highlighting
13
15
  #
14
16
  # @param code_content [String] The Ruby code to display
@@ -68,12 +70,6 @@ module LanguageOperator
68
70
  puts highlighted
69
71
  puts
70
72
  end
71
-
72
- private
73
-
74
- def pastel
75
- @pastel ||= Pastel.new
76
- end
77
73
  end
78
74
  end
79
75
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'pastel'
3
+ require_relative '../helpers/pastel_helper'
4
4
  require 'json'
5
5
  require 'time'
6
6
 
@@ -10,6 +10,8 @@ module LanguageOperator
10
10
  # Formatter for displaying agent execution logs with color and icons
11
11
  class LogFormatter
12
12
  class << self
13
+ include Helpers::PastelHelper
14
+
13
15
  # Format a single log line from kubectl output
14
16
  #
15
17
  # @param line [String] Raw log line from kubectl (with [pod/container] prefix)
@@ -33,10 +35,6 @@ module LanguageOperator
33
35
 
34
36
  private
35
37
 
36
- def pastel
37
- @pastel ||= Pastel.new
38
- end
39
-
40
38
  # Parse the kubectl prefix from the log line
41
39
  # Returns [prefix, content] or [nil, original_line]
42
40
  def parse_kubectl_prefix(line)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'tty-spinner'
4
- require 'pastel'
4
+ require_relative '../helpers/pastel_helper'
5
5
 
6
6
  module LanguageOperator
7
7
  module CLI
@@ -9,6 +9,8 @@ module LanguageOperator
9
9
  # Beautiful progress output for CLI operations
10
10
  class ProgressFormatter
11
11
  class << self
12
+ include Helpers::PastelHelper
13
+
12
14
  def with_spinner(message, success_msg: nil, &block)
13
15
  spinner = TTY::Spinner.new("[:spinner] #{message}...", format: :dots, success_mark: pastel.green('✔'))
14
16
  spinner.auto_spin
@@ -40,12 +42,6 @@ module LanguageOperator
40
42
  def warn(message)
41
43
  puts "[#{pastel.yellow('⚠')}] #{message}"
42
44
  end
43
-
44
- private
45
-
46
- def pastel
47
- @pastel ||= Pastel.new
48
- end
49
45
  end
50
46
  end
51
47
  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
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'tty-table'
4
- require 'pastel'
4
+ require_relative '../helpers/pastel_helper'
5
+ require_relative 'status_formatter'
5
6
 
6
7
  module LanguageOperator
7
8
  module CLI
@@ -9,6 +10,8 @@ module LanguageOperator
9
10
  # Table output for CLI list commands
10
11
  class TableFormatter
11
12
  class << self
13
+ include Helpers::PastelHelper
14
+
12
15
  def clusters(clusters)
13
16
  return ProgressFormatter.info('No clusters found') if clusters.empty?
14
17
 
@@ -20,7 +23,7 @@ module LanguageOperator
20
23
  cluster[:agents] || 0,
21
24
  cluster[:tools] || 0,
22
25
  cluster[:models] || 0,
23
- status_indicator(cluster[:status])
26
+ StatusFormatter.format(cluster[:status] || 'Unknown')
24
27
  ]
25
28
  end
26
29
 
@@ -36,7 +39,7 @@ module LanguageOperator
36
39
  [
37
40
  agent[:name],
38
41
  agent[:mode],
39
- status_indicator(agent[:status]),
42
+ StatusFormatter.format(agent[:status]),
40
43
  agent[:next_run] || 'N/A',
41
44
  agent[:executions] || 0
42
45
  ]
@@ -58,7 +61,7 @@ module LanguageOperator
58
61
  cluster_name,
59
62
  agent[:name],
60
63
  agent[:mode],
61
- status_indicator(agent[:status]),
64
+ StatusFormatter.format(agent[:status]),
62
65
  agent[:next_run] || 'N/A',
63
66
  agent[:executions] || 0
64
67
  ]
@@ -77,7 +80,7 @@ module LanguageOperator
77
80
  [
78
81
  tool[:name],
79
82
  tool[:type],
80
- status_indicator(tool[:status]),
83
+ StatusFormatter.format(tool[:status]),
81
84
  tool[:agents_using] || 0
82
85
  ]
83
86
  end
@@ -112,7 +115,7 @@ module LanguageOperator
112
115
  model[:name],
113
116
  model[:provider],
114
117
  model[:model],
115
- status_indicator(model[:status])
118
+ StatusFormatter.format(model[:status])
116
119
  ]
117
120
  end
118
121
 
@@ -133,7 +136,7 @@ module LanguageOperator
133
136
  cluster[:agents] || 0,
134
137
  cluster[:tools] || 0,
135
138
  cluster[:models] || 0,
136
- status_indicator(cluster[:status])
139
+ StatusFormatter.format(cluster[:status] || 'Unknown')
137
140
  ]
138
141
  end
139
142
 
@@ -148,30 +151,11 @@ module LanguageOperator
148
151
 
149
152
  private
150
153
 
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
154
  def truncate(text, length)
167
155
  return text if text.nil? || text.length <= length
168
156
 
169
157
  "#{text[0...(length - 3)]}..."
170
158
  end
171
-
172
- def pastel
173
- @pastel ||= Pastel.new
174
- end
175
159
  end
176
160
  end
177
161
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pastel'
4
+
5
+ module LanguageOperator
6
+ module CLI
7
+ module Helpers
8
+ # Shared module providing Pastel color functionality
9
+ # to CLI commands, formatters, and helpers.
10
+ #
11
+ # Usage:
12
+ # include PastelHelper
13
+ # puts pastel.green("Success!")
14
+ module PastelHelper
15
+ # Returns a memoized Pastel instance for colorizing terminal output
16
+ #
17
+ # @return [Pastel] Pastel instance
18
+ def pastel
19
+ @pastel ||= Pastel.new
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -50,24 +50,6 @@ module LanguageOperator
50
50
  def self.tool_usage_count(agents, tool_name)
51
51
  agents_using_tool(agents, tool_name).size
52
52
  end
53
-
54
- # Count how many agents use a specific model
55
- #
56
- # @param agents [Array<Hash>] Array of agent resources
57
- # @param model_name [String] Name of the model
58
- # @return [Integer] Count of agents using this model
59
- def self.model_usage_count(agents, model_name)
60
- agents_using_model(agents, model_name).size
61
- end
62
-
63
- # Count how many agents use a specific persona
64
- #
65
- # @param agents [Array<Hash>] Array of agent resources
66
- # @param persona_name [String] Name of the persona
67
- # @return [Integer] Count of agents using this persona
68
- def self.persona_usage_count(agents, persona_name)
69
- agents_using_persona(agents, persona_name).size
70
- end
71
53
  end
72
54
  end
73
55
  end
@@ -11,6 +11,7 @@ require_relative 'commands/tool'
11
11
  require_relative 'commands/model'
12
12
  require_relative 'commands/quickstart'
13
13
  require_relative 'commands/install'
14
+ require_relative 'commands/system'
14
15
  require_relative 'formatters/progress_formatter'
15
16
  require_relative '../config/cluster_config'
16
17
  require_relative '../kubernetes/client'
@@ -88,6 +89,9 @@ module LanguageOperator
88
89
  desc 'model SUBCOMMAND ...ARGS', 'Manage language models'
89
90
  subcommand 'model', Commands::Model
90
91
 
92
+ desc 'system SUBCOMMAND ...ARGS', 'System commands for schema and metadata'
93
+ subcommand 'system', Commands::System
94
+
91
95
  desc 'quickstart', 'Interactive setup wizard for first-time users'
92
96
  def quickstart
93
97
  Commands::Quickstart.new.invoke(:start)
@@ -413,7 +413,6 @@ module LanguageOperator
413
413
  end
414
414
 
415
415
  def create_model_resource(cluster_info, name, provider, model, api_key = nil, endpoint = nil)
416
- # rubocop:disable Metrics/BlockLength
417
416
  Formatters::ProgressFormatter.with_spinner("Creating model '#{name}'") do
418
417
  k8s = Kubernetes::Client.new(
419
418
  kubeconfig: cluster_info[:kubeconfig],
@@ -34,12 +34,13 @@ module LanguageOperator
34
34
  {
35
35
  'llm' => {
36
36
  'provider' => detect_provider_from_env,
37
- 'model' => ENV.fetch('LLM_MODEL') { default_model_from_env },
37
+ 'model' => LanguageOperator::Config.get('LLM_MODEL', default: default_model_from_env),
38
38
  'endpoint' => parse_model_endpoint_from_env,
39
- 'api_key' => ENV.fetch('OPENAI_API_KEY') { ENV.fetch('ANTHROPIC_API_KEY', 'dummy-key-for-local-proxy') }
39
+ 'api_key' => LanguageOperator::Config.get('OPENAI_API_KEY', 'ANTHROPIC_API_KEY',
40
+ default: 'dummy-key-for-local-proxy')
40
41
  },
41
42
  'mcp_servers' => parse_mcp_servers_from_env,
42
- 'debug' => ENV['DEBUG'] == 'true'
43
+ 'debug' => LanguageOperator::Config.get_bool('DEBUG', default: false)
43
44
  }
44
45
  end
45
46
 
@@ -49,15 +50,12 @@ module LanguageOperator
49
50
  #
50
51
  # @return [String, nil] Model endpoint URL
51
52
  def self.parse_model_endpoint_from_env
52
- # Support MODEL_ENDPOINTS (operator sets this)
53
- endpoints_env = ENV.fetch('MODEL_ENDPOINTS', nil)
54
- if endpoints_env && !endpoints_env.empty?
55
- # Take the first endpoint from comma-separated list
56
- endpoints_env.split(',').first.strip
57
- else
58
- # Fallback to legacy OPENAI_ENDPOINT
59
- ENV.fetch('OPENAI_ENDPOINT', nil)
60
- end
53
+ # Support MODEL_ENDPOINTS (operator sets this) - take first from comma-separated list
54
+ endpoints = LanguageOperator::Config.get_array('MODEL_ENDPOINTS')
55
+ return endpoints.first unless endpoints.empty?
56
+
57
+ # Fallback to legacy OPENAI_ENDPOINT
58
+ LanguageOperator::Config.get('OPENAI_ENDPOINT')
61
59
  end
62
60
 
63
61
  # Load configuration with automatic fallback to environment variables
@@ -79,11 +77,11 @@ module LanguageOperator
79
77
  # @return [String] Provider name (openai_compatible, openai, or anthropic)
80
78
  # @raise [RuntimeError] If no API key or endpoint is found
81
79
  def self.detect_provider_from_env
82
- if ENV['OPENAI_ENDPOINT'] || ENV['MODEL_ENDPOINTS']
80
+ if LanguageOperator::Config.set?('OPENAI_ENDPOINT', 'MODEL_ENDPOINTS')
83
81
  'openai_compatible'
84
- elsif ENV['OPENAI_API_KEY']
82
+ elsif LanguageOperator::Config.set?('OPENAI_API_KEY')
85
83
  'openai'
86
- elsif ENV['ANTHROPIC_API_KEY']
84
+ elsif LanguageOperator::Config.set?('ANTHROPIC_API_KEY')
87
85
  'anthropic'
88
86
  else
89
87
  raise 'No API key or endpoint found. Set OPENAI_ENDPOINT or MODEL_ENDPOINTS for local LLM, ' \
@@ -109,21 +107,22 @@ module LanguageOperator
109
107
  # @return [Array<Hash>] Array of MCP server configurations
110
108
  def self.parse_mcp_servers_from_env
111
109
  # Support both MCP_SERVERS (comma-separated) and legacy MCP_URL
112
- servers_env = ENV.fetch('MCP_SERVERS', nil)
113
- if servers_env && !servers_env.empty?
110
+ servers = LanguageOperator::Config.get_array('MCP_SERVERS')
111
+
112
+ if !servers.empty?
114
113
  # Parse comma-separated URLs
115
- servers_env.split(',').map.with_index do |url, index|
114
+ servers.map.with_index do |url, index|
116
115
  {
117
116
  'name' => "default-tools-#{index}",
118
- 'url' => url.strip,
117
+ 'url' => url,
119
118
  'transport' => 'streamable',
120
119
  'enabled' => true
121
120
  }
122
121
  end
123
- elsif ENV['MCP_URL']
122
+ elsif (url = LanguageOperator::Config.get('MCP_URL'))
124
123
  [{
125
124
  'name' => 'default-tools',
126
- 'url' => ENV['MCP_URL'],
125
+ 'url' => url,
127
126
  'transport' => 'streamable',
128
127
  'enabled' => true
129
128
  }]
@@ -8,8 +8,9 @@ module LanguageOperator
8
8
  # - Key-to-env-var mappings
9
9
  # - Prefixes (e.g., SMTP_HOST from prefix: 'SMTP')
10
10
  # - Default values
11
- # - Type conversion (string, integer, boolean, float)
11
+ # - Type conversion (string, integer, boolean, float, array)
12
12
  # - Required config validation
13
+ # - Multiple fallback keys
13
14
  #
14
15
  # @example Load SMTP configuration
15
16
  # config = LanguageOperator::Config.load(
@@ -75,7 +76,8 @@ module LanguageOperator
75
76
  # Convert a string value to the specified type
76
77
  #
77
78
  # @param value [String, nil] Raw string value from environment
78
- # @param type [Symbol] Target type (:string, :integer, :boolean, :float)
79
+ # @param type [Symbol] Target type (:string, :integer, :boolean, :float, :array)
80
+ # @param separator [String] Separator for array type (default: ',')
79
81
  # @return [Object] Converted value
80
82
  #
81
83
  # @example String conversion
@@ -92,7 +94,10 @@ module LanguageOperator
92
94
  #
93
95
  # @example Float conversion
94
96
  # Config.convert_type('3.14', :float) # => 3.14
95
- def self.convert_type(value, type)
97
+ #
98
+ # @example Array conversion
99
+ # Config.convert_type('a,b,c', :array) # => ["a", "b", "c"]
100
+ def self.convert_type(value, type, separator: ',')
96
101
  return nil if value.nil?
97
102
 
98
103
  case type
@@ -104,6 +109,10 @@ module LanguageOperator
104
109
  value.to_f
105
110
  when :boolean
106
111
  %w[true 1 yes on].include?(value.to_s.downcase)
112
+ when :array
113
+ return [] if value.to_s.empty?
114
+
115
+ value.to_s.split(separator).map(&:strip).reject(&:empty?)
107
116
  else
108
117
  value
109
118
  end
@@ -134,5 +143,108 @@ module LanguageOperator
134
143
  validate_required!(config, required) unless required.empty?
135
144
  config
136
145
  end
146
+
147
+ # Get environment variable with multiple fallback keys
148
+ #
149
+ # @param keys [Array<String>] Environment variable names to try
150
+ # @param default [Object, nil] Default value if none found
151
+ # @return [String, nil] The first non-nil value or default
152
+ #
153
+ # @example
154
+ # Config.get('SMTP_HOST', 'MAIL_HOST', default: 'localhost')
155
+ def self.get(*keys, default: nil)
156
+ keys.each do |key|
157
+ value = ENV.fetch(key.to_s, nil)
158
+ return value if value
159
+ end
160
+ default
161
+ end
162
+
163
+ # Get required environment variable with fallback keys
164
+ #
165
+ # @param keys [Array<String>] Environment variable names to try
166
+ # @return [String] The first non-nil value
167
+ # @raise [ArgumentError] If none of the keys are set
168
+ #
169
+ # @example
170
+ # Config.require('DATABASE_URL', 'DB_URL')
171
+ def self.require(*keys)
172
+ value = get(*keys)
173
+ raise ArgumentError, "Missing required configuration: #{keys.join(' or ')}" unless value
174
+
175
+ value
176
+ end
177
+
178
+ # Get environment variable as integer
179
+ #
180
+ # @param keys [Array<String>] Environment variable names to try
181
+ # @param default [Integer, nil] Default value if none found
182
+ # @return [Integer, nil] The value converted to integer, or default
183
+ #
184
+ # @example
185
+ # Config.get_int('MAX_WORKERS', default: 4)
186
+ def self.get_int(*keys, default: nil)
187
+ value = get(*keys)
188
+ return default if value.nil?
189
+
190
+ value.to_i
191
+ end
192
+
193
+ # Get environment variable as boolean
194
+ #
195
+ # Treats 'true', '1', 'yes', 'on' as true (case insensitive).
196
+ #
197
+ # @param keys [Array<String>] Environment variable names to try
198
+ # @param default [Boolean] Default value if none found
199
+ # @return [Boolean] The value as boolean
200
+ #
201
+ # @example
202
+ # Config.get_bool('USE_TLS', 'ENABLE_TLS', default: true)
203
+ def self.get_bool(*keys, default: false)
204
+ value = get(*keys)
205
+ return default if value.nil?
206
+
207
+ %w[true 1 yes on].include?(value.to_s.downcase)
208
+ end
209
+
210
+ # Get environment variable as array (split by separator)
211
+ #
212
+ # @param keys [Array<String>] Environment variable names to try
213
+ # @param default [Array] Default value if none found
214
+ # @param separator [String] Character to split on (default: ',')
215
+ # @return [Array<String>] The value split into array
216
+ #
217
+ # @example
218
+ # Config.get_array('ALLOWED_HOSTS', separator: ',')
219
+ def self.get_array(*keys, default: [], separator: ',')
220
+ value = get(*keys)
221
+ return default if value.nil? || value.empty?
222
+
223
+ value.split(separator).map(&:strip).reject(&:empty?)
224
+ end
225
+
226
+ # Check if environment variable is set (even if empty string)
227
+ #
228
+ # @param keys [Array<String>] Environment variable names to check
229
+ # @return [Boolean] True if any key is set
230
+ #
231
+ # @example
232
+ # Config.set?('DEBUG', 'VERBOSE')
233
+ def self.set?(*keys)
234
+ keys.any? { |key| ENV.key?(key.to_s) }
235
+ end
236
+
237
+ # Get all environment variables matching a prefix
238
+ #
239
+ # @param prefix [String] Prefix to match
240
+ # @return [Hash<String, String>] Hash with prefix removed from keys
241
+ #
242
+ # @example
243
+ # Config.with_prefix('DATABASE_')
244
+ # # Returns { 'URL' => '...', 'POOL_SIZE' => '5' } for DATABASE_URL and DATABASE_POOL_SIZE
245
+ def self.with_prefix(prefix)
246
+ ENV.select { |key, _| key.start_with?(prefix) }
247
+ .transform_keys { |key| key.sub(prefix, '') }
248
+ end
137
249
  end
138
250
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LanguageOperator
4
+ # Shared constants used across the Language Operator gem
5
+ module Constants
6
+ # Agent execution modes with their aliases
7
+ # Primary modes are the canonical forms used in CRD schemas
8
+ # Aliases are accepted for backwards compatibility and convenience
9
+ EXECUTION_MODES = {
10
+ autonomous: %w[autonomous interactive].freeze,
11
+ scheduled: %w[scheduled event-driven].freeze,
12
+ reactive: %w[reactive http webhook].freeze
13
+ }.freeze
14
+
15
+ # Primary (canonical) execution modes for schema definitions
16
+ PRIMARY_MODES = %w[autonomous scheduled reactive].freeze
17
+
18
+ # All valid mode strings (primary + aliases)
19
+ ALL_MODE_ALIASES = EXECUTION_MODES.values.flatten.freeze
20
+
21
+ # Normalizes a mode string to its primary canonical form
22
+ #
23
+ # @param mode_string [String] The mode string to normalize
24
+ # @return [String] The canonical primary mode
25
+ # @raise [ArgumentError] if the mode string is not recognized
26
+ #
27
+ # @example
28
+ # Constants.normalize_mode('interactive') # => 'autonomous'
29
+ # Constants.normalize_mode('webhook') # => 'reactive'
30
+ # Constants.normalize_mode('scheduled') # => 'scheduled'
31
+ def self.normalize_mode(mode_string)
32
+ return nil if mode_string.nil?
33
+
34
+ mode_string = mode_string.to_s.downcase.strip
35
+
36
+ EXECUTION_MODES.each do |primary, aliases|
37
+ return primary.to_s if aliases.include?(mode_string)
38
+ end
39
+
40
+ raise ArgumentError, "Unknown execution mode: #{mode_string}. " \
41
+ "Valid modes: #{ALL_MODE_ALIASES.join(', ')}"
42
+ end
43
+
44
+ # Validates that a mode string is recognized
45
+ #
46
+ # @param mode_string [String] The mode string to validate
47
+ # @return [Boolean] true if valid, false otherwise
48
+ def self.valid_mode?(mode_string)
49
+ return false if mode_string.nil?
50
+
51
+ ALL_MODE_ALIASES.include?(mode_string.to_s.downcase.strip)
52
+ end
53
+ end
54
+ end