ace-support-core 0.29.0

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.
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+
6
+ module Ace
7
+ module Core
8
+ module Atoms
9
+ # Pure command execution functions with safety features
10
+ # Configuration is loaded from .ace-defaults/core/settings.yml
11
+ # and can be overridden at .ace/core/settings.yml (ADR-022)
12
+ module CommandExecutor
13
+ # Fallback timeout value if config is not available
14
+ DEFAULT_TIMEOUT = 30
15
+
16
+ # Maximum output size (1MB)
17
+ MAX_OUTPUT_SIZE = 1_048_576
18
+
19
+ module_function
20
+
21
+ # Get configured timeout from config cascade
22
+ # Falls back to DEFAULT_TIMEOUT if config is unavailable
23
+ # @return [Integer] Configured timeout in seconds
24
+ def configured_timeout
25
+ Ace::Core.get("core", "command_executor", "timeout") || DEFAULT_TIMEOUT
26
+ rescue
27
+ DEFAULT_TIMEOUT
28
+ end
29
+
30
+ # Execute a command with timeout and output capture
31
+ # @param command [String] Command to execute
32
+ # @param timeout [Integer] Timeout in seconds (defaults to config value)
33
+ # @param max_output [Integer] Maximum output size in bytes
34
+ # @param cwd [String] Working directory for command
35
+ # @return [Hash] {success: Boolean, stdout: String, stderr: String, exit_code: Integer, error: String}
36
+ def execute(command, timeout: nil, max_output: MAX_OUTPUT_SIZE, cwd: nil)
37
+ timeout ||= configured_timeout
38
+ return {success: false, error: "Command cannot be nil"} if command.nil?
39
+ return {success: false, error: "Command cannot be empty"} if command.strip.empty?
40
+
41
+ stdout_data = String.new
42
+ stderr_data = String.new
43
+ exit_status = nil
44
+ truncated = false
45
+
46
+ options = {}
47
+ options[:chdir] = cwd if cwd && Dir.exist?(cwd)
48
+
49
+ begin
50
+ Timeout.timeout(timeout) do
51
+ Open3.popen3(command, options) do |stdin, stdout, stderr, wait_thr|
52
+ stdin.close
53
+
54
+ # Read output with size limits
55
+ stdout_reader = Thread.new do
56
+ Thread.current.report_on_exception = false
57
+ begin
58
+ stdout.each_char do |char|
59
+ if stdout_data.bytesize < max_output
60
+ stdout_data << char
61
+ else
62
+ truncated = true
63
+ break
64
+ end
65
+ end
66
+ rescue IOError
67
+ # Stream was closed, this is expected on timeout
68
+ end
69
+ end
70
+
71
+ stderr_reader = Thread.new do
72
+ Thread.current.report_on_exception = false
73
+ begin
74
+ stderr.each_char do |char|
75
+ if stderr_data.bytesize < max_output
76
+ stderr_data << char
77
+ else
78
+ truncated = true
79
+ break
80
+ end
81
+ end
82
+ rescue IOError
83
+ # Stream was closed, this is expected on timeout
84
+ end
85
+ end
86
+
87
+ stdout_reader.join
88
+ stderr_reader.join
89
+
90
+ exit_status = wait_thr.value
91
+ end
92
+ end
93
+
94
+ result = {
95
+ success: exit_status.success?,
96
+ stdout: stdout_data,
97
+ stderr: stderr_data,
98
+ exit_code: exit_status.exitstatus
99
+ }
100
+
101
+ result[:warning] = "Output truncated (exceeded #{max_output} bytes)" if truncated
102
+
103
+ result
104
+ rescue Timeout::Error
105
+ {
106
+ success: false,
107
+ stdout: stdout_data,
108
+ stderr: stderr_data,
109
+ error: "Command timed out after #{timeout} seconds"
110
+ }
111
+ rescue Errno::ENOENT
112
+ {
113
+ success: false,
114
+ error: "Command not found: #{command.split.first}"
115
+ }
116
+ rescue => e
117
+ {
118
+ success: false,
119
+ stdout: stdout_data,
120
+ stderr: stderr_data,
121
+ error: "Command execution failed: #{e.message}"
122
+ }
123
+ end
124
+ end
125
+
126
+ # Execute command and return only stdout if successful
127
+ # @param command [String] Command to execute
128
+ # @param options [Hash] Execution options
129
+ # @return [String, nil] Command output or nil if failed
130
+ def capture(command, **options)
131
+ result = execute(command, **options)
132
+ result[:success] ? result[:stdout] : nil
133
+ end
134
+
135
+ # Check if a command is available in PATH
136
+ # @param command [String] Command name to check
137
+ # @return [Boolean] true if command is available
138
+ def available?(command)
139
+ return false if command.nil? || command.empty?
140
+
141
+ # Extract just the command name (first word)
142
+ cmd = command.split.first
143
+
144
+ # Check if command exists in PATH
145
+ ENV["PATH"].split(File::PATH_SEPARATOR).any? do |path|
146
+ executable = File.join(path, cmd)
147
+ File.executable?(executable) && !File.directory?(executable)
148
+ end
149
+ rescue
150
+ false
151
+ end
152
+
153
+ # Execute command with real-time output streaming
154
+ # @param command [String] Command to execute
155
+ # @param output_callback [Proc] Callback for output lines
156
+ # @param timeout [Integer] Timeout in seconds (defaults to config value)
157
+ # @param cwd [String] Working directory
158
+ # @return [Hash] {success: Boolean, exit_code: Integer, error: String}
159
+ def stream(command, output_callback: nil, timeout: nil, cwd: nil)
160
+ timeout ||= configured_timeout
161
+ return {success: false, error: "Command cannot be nil"} if command.nil?
162
+
163
+ options = {}
164
+ options[:chdir] = cwd if cwd && Dir.exist?(cwd)
165
+
166
+ exit_status = nil
167
+
168
+ begin
169
+ Timeout.timeout(timeout) do
170
+ Open3.popen2e(command, options) do |stdin, stdout_err, wait_thr|
171
+ stdin.close
172
+
173
+ stdout_err.each_line do |line|
174
+ output_callback&.call(line.chomp)
175
+ end
176
+
177
+ exit_status = wait_thr.value
178
+ end
179
+ end
180
+
181
+ {
182
+ success: exit_status.success?,
183
+ exit_code: exit_status.exitstatus
184
+ }
185
+ rescue Timeout::Error
186
+ {
187
+ success: false,
188
+ error: "Command timed out after #{timeout} seconds"
189
+ }
190
+ rescue Errno::ENOENT
191
+ {
192
+ success: false,
193
+ error: "Command not found: #{command.split.first}"
194
+ }
195
+ rescue => e
196
+ {
197
+ success: false,
198
+ error: "Command execution failed: #{e.message}"
199
+ }
200
+ end
201
+ end
202
+
203
+ # Execute multiple commands in sequence
204
+ # @param commands [Array<String>] Commands to execute
205
+ # @param options [Hash] Execution options
206
+ # @return [Array<Hash>] Results for each command
207
+ def execute_batch(commands, **options)
208
+ return [] if commands.nil? || commands.empty?
209
+
210
+ commands.map do |command|
211
+ result = execute(command, **options)
212
+ result[:command] = command
213
+ result
214
+ end
215
+ end
216
+
217
+ # Build a safe command string with proper escaping
218
+ # @param command [String] Base command
219
+ # @param args [Array<String>] Arguments to add
220
+ # @return [String] Safe command string
221
+ def build_command(command, *args)
222
+ return nil if command.nil?
223
+
224
+ escaped_args = args.flatten.compact.map do |arg|
225
+ # Shell escape the argument
226
+ if arg.match?(/[^A-Za-z0-9_\-.,:\/@]/)
227
+ # Properly escape single quotes in shell arguments
228
+ "'#{arg.gsub("'", "'\\''")}'"
229
+ else
230
+ arg
231
+ end
232
+ end
233
+
234
+ [command, *escaped_args].join(" ")
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ # Core functionality for ACE gems
5
+ module Core
6
+ # Atomic operations - pure functions with no side effects
7
+ # Atoms are the building blocks for more complex operations.
8
+ # See ADR-011 for ATOM architecture details.
9
+ module Atoms
10
+ # ConfigSummary displays effective configuration state to stderr.
11
+ #
12
+ # Only shows values that differ from defaults, filtering sensitive keys.
13
+ # Output format: "Config: key=value key2=value2" (space-separated)
14
+ #
15
+ # ## Sensitive Key Filtering
16
+ #
17
+ # Keys ending with sensitive patterns are filtered out:
18
+ # - token, password, secret, credential, key, api_key
19
+ #
20
+ # Keys containing these patterns but NOT ending with them are shown:
21
+ # - max_tokens (contains "tokens" but doesn't end with it) ✓ shown
22
+ # - keyboard_layout (contains "key" but doesn't end with it) ✓ shown
23
+ # - auth_token (ends with "token") ✗ filtered
24
+ # - api_key (ends with "key") ✗ filtered
25
+ #
26
+ # ## Usage
27
+ #
28
+ # ConfigSummary.display(
29
+ # command: "review",
30
+ # config: Gem.config, # Effective config
31
+ # defaults: Gem.default_config, # Defaults for diffing
32
+ # options: { verbose: true }, # Thor options hash
33
+ # quiet: false,
34
+ # summary_keys: %w[model preset] # Optional allowlist
35
+ # )
36
+ # # Output to stderr: "Config: model=gflash preset=pr"
37
+ #
38
+ class ConfigSummary
39
+ # Match keys ENDING with sensitive words (not containing them)
40
+ SENSITIVE_REGEX = /(_|^)(token|password|secret|credential|key|api_key)$/i
41
+
42
+ # Display configuration summary to stderr
43
+ #
44
+ # @param command [String] Command name for context
45
+ # @param config [Hash] Effective configuration (merged result)
46
+ # @param defaults [Hash] Default configuration to diff against
47
+ # @param options [Hash] CLI options (Thor options hash)
48
+ # @param quiet [Boolean] Suppress output if true
49
+ # @param summary_keys [Array<String>, nil] Allowlist of keys to include (nil = all non-sensitive)
50
+ # @return [nil]
51
+ #
52
+ # @example Basic usage
53
+ # ConfigSummary.display(
54
+ # command: "review",
55
+ # config: { model: "gflash", format: "markdown" },
56
+ # defaults: { "model" => "glite", "format" => "markdown" },
57
+ # options: { verbose: true }
58
+ # )
59
+ # # Outputs: "Config: model=gflash verbose=true"
60
+ #
61
+ # @example With quiet mode
62
+ # ConfigSummary.display(
63
+ # command: "review",
64
+ # config: { model: "gflash" },
65
+ # defaults: {},
66
+ # options: {},
67
+ # quiet: true
68
+ # )
69
+ # # No output
70
+ #
71
+ # @example With allowlist
72
+ # ConfigSummary.display(
73
+ # command: "review",
74
+ # config: { model: "gflash", format: "markdown" },
75
+ # defaults: {},
76
+ # options: { verbose: true },
77
+ # summary_keys: %w[model]
78
+ # )
79
+ # # Outputs: "Config: model=gflash"
80
+ # # (format and verbose not in allowlist)
81
+ #
82
+ def self.display(command:, config: {}, defaults: {}, options: {}, quiet: false, summary_keys: nil)
83
+ return if quiet
84
+
85
+ # Only display config when verbose mode is explicitly enabled
86
+ return unless options[:verbose]
87
+
88
+ summary = new(command, config, defaults, options, summary_keys).build
89
+ warn "Config: #{summary}" unless summary.empty?
90
+ end
91
+
92
+ # Display configuration summary only if help was NOT requested.
93
+ #
94
+ # This is the recommended method for commands that need to show config
95
+ # but want to avoid polluting --help output with configuration details.
96
+ #
97
+ # @param command [String] Command name for context
98
+ # @param config [Hash] Effective configuration (merged result)
99
+ # @param defaults [Hash] Default configuration to diff against
100
+ # @param options [Hash] CLI options (Thor options hash)
101
+ # @param quiet [Boolean] Suppress output if true
102
+ # @param summary_keys [Array<String>, nil] Allowlist of keys to include (nil = all non-sensitive)
103
+ # @return [nil]
104
+ #
105
+ # @example Usage in command
106
+ # def execute
107
+ # # Check for help BEFORE displaying config
108
+ # return show_help if help_requested?(options, args)
109
+ #
110
+ # # Now safe to display config
111
+ # ConfigSummary.display_if_needed(
112
+ # command: "review",
113
+ # config: Gem.config,
114
+ # defaults: Gem.default_config,
115
+ # options: options
116
+ # )
117
+ # end
118
+ #
119
+ def self.display_if_needed(command:, config: {}, defaults: {}, options: {}, quiet: false, summary_keys: nil, args: ARGV)
120
+ return if quiet
121
+ return if help_requested?(options, args)
122
+
123
+ display(command: command, config: config, defaults: defaults, options: options, summary_keys: summary_keys)
124
+ end
125
+
126
+ # Check if help was requested via options or arguments.
127
+ #
128
+ # @param options [Hash] CLI options hash (from Thor)
129
+ # @param args [Array<String>] Command arguments (defaults to ARGV for CLI context)
130
+ # @return [Boolean] true if help was requested
131
+ #
132
+ # @note The `args` parameter defaults to ARGV for CLI usage convenience.
133
+ # In test or non-CLI contexts, pass an explicit array to avoid global state.
134
+ #
135
+ def self.help_requested?(options = {}, args = ARGV)
136
+ options[:help] ||
137
+ options[:h] ||
138
+ args.include?("--help") ||
139
+ args.include?("-h")
140
+ end
141
+
142
+ def initialize(command, config, defaults, options, summary_keys)
143
+ @command = command
144
+ @config = flatten_hash(config || {})
145
+ @defaults = flatten_hash(defaults || {})
146
+ @options = options || {}
147
+ @summary_keys = summary_keys&.map(&:to_s)
148
+ end
149
+
150
+ # Build the configuration summary string
151
+ #
152
+ # @return [String] Space-separated key=value pairs
153
+ def build
154
+ pairs = []
155
+
156
+ # 1. Add CLI options that were explicitly set (non-nil, non-false)
157
+ @options.each do |key, value|
158
+ next if value.nil? || value == false
159
+ next if sensitive_key?(key.to_s)
160
+ next if @summary_keys && !@summary_keys.include?(key.to_s)
161
+ pairs << format_pair(key, value)
162
+ end
163
+
164
+ # 2. Add config values that differ from defaults (if not already in options)
165
+ @config.each do |key, value|
166
+ # Skip if already shown from options (check both symbol and string)
167
+ next if @options.key?(key.to_sym) || @options.key?(key.to_s)
168
+ next if @defaults[key] == value
169
+ next if sensitive_key?(key)
170
+ next if @summary_keys && !@summary_keys.include?(key)
171
+ pairs << format_pair(key, value)
172
+ end
173
+
174
+ pairs.sort.join(" ")
175
+ end
176
+
177
+ private
178
+
179
+ # Flatten nested hash to dot notation
180
+ # { llm: { provider: "google" } } → { "llm.provider" => "google" }
181
+ #
182
+ # @param hash [Hash] Hash to flatten
183
+ # @param prefix [String, nil] Current key prefix
184
+ # @return [Hash] Flattened hash with dot-notation keys
185
+ def flatten_hash(hash, prefix = nil)
186
+ hash.each_with_object({}) do |(key, value), result|
187
+ full_key = prefix ? "#{prefix}.#{key}" : key.to_s
188
+ if value.is_a?(Hash)
189
+ result.merge!(flatten_hash(value, full_key))
190
+ else
191
+ result[full_key] = value
192
+ end
193
+ end
194
+ end
195
+
196
+ # Format a key-value pair for output
197
+ #
198
+ # @param key [String, Symbol] Key
199
+ # @param value [Object] Value
200
+ # @return [String] Formatted "key=value" string
201
+ def format_pair(key, value)
202
+ value_str = case value
203
+ when true then "true"
204
+ when Array then value.join(",")
205
+ else value.to_s
206
+ end
207
+ "#{key}=#{value_str}"
208
+ end
209
+
210
+ # Check if a key is sensitive (should be filtered)
211
+ #
212
+ # @param key [String, Symbol] Key to check
213
+ # @return [Boolean] true if key is sensitive
214
+ def sensitive_key?(key)
215
+ key.to_s.match?(SENSITIVE_REGEX)
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Core
5
+ module Atoms
6
+ # Pure .env file parsing functions
7
+ module EnvParser
8
+ module_function
9
+
10
+ # Parse .env file content into hash
11
+ # @param content [String] .env file content
12
+ # @return [Hash] Parsed environment variables
13
+ def parse(content)
14
+ return {} if content.nil? || content.strip.empty?
15
+
16
+ result = {}
17
+ lines = content.lines.map(&:strip)
18
+
19
+ lines.each do |line|
20
+ # Skip empty lines and comments
21
+ next if line.empty? || line.start_with?("#")
22
+
23
+ # Parse KEY=VALUE format
24
+ if (match = line.match(/\A([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\z/))
25
+ key = match[1]
26
+ value = match[2]
27
+
28
+ # Handle quoted values
29
+ value = unquote(value)
30
+
31
+ result[key] = value
32
+ end
33
+ end
34
+
35
+ result
36
+ end
37
+
38
+ # Format hash as .env content
39
+ # @param env_hash [Hash] Environment variables
40
+ # @return [String] Formatted .env content
41
+ def format(env_hash)
42
+ return "" if env_hash.nil? || env_hash.empty?
43
+
44
+ env_hash.map do |key, value|
45
+ formatted_value = value.to_s.include?(" ") ? %("#{value}") : value.to_s
46
+ "#{key}=#{formatted_value}"
47
+ end.join("\n")
48
+ end
49
+
50
+ # Validate environment variable name
51
+ # @param name [String] Variable name to validate
52
+ # @return [Boolean] true if valid
53
+ def valid_key?(name)
54
+ !name.nil? && name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
55
+ end
56
+
57
+ # Remove quotes from value if present
58
+ # @param value [String] Value that may be quoted
59
+ # @return [String] Unquoted value
60
+ def unquote(value)
61
+ return value unless value.is_a?(String)
62
+
63
+ # Handle double quotes
64
+ if value.start_with?('"') && value.end_with?('"')
65
+ value[1..-2].gsub('\\"', '"').gsub("\\n", "\n").gsub("\\\\", "\\")
66
+ # Handle single quotes
67
+ elsif value.start_with?("'") && value.end_with?("'")
68
+ value[1..-2]
69
+ else
70
+ value
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end