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,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/fs"
4
+ require "fileutils"
5
+ require "yaml"
6
+
7
+ module Ace
8
+ module Core
9
+ module Molecules
10
+ # Standardized prompt cache management for ace-* gems
11
+ # Provides consistent session directory creation, prompt saving, and metadata management
12
+ #
13
+ # This is a stateless utility class - all methods are class methods.
14
+ class PromptCacheManager
15
+ # Custom exception for prompt cache operations
16
+ class PromptCacheError < StandardError; end
17
+ class << self
18
+ # Create a new session directory with standardized structure
19
+ # @param gem_name [String] Name of the gem (e.g., 'ace-review', 'ace-docs')
20
+ # @param operation [String] Operation name (e.g., 'review', 'analyze-consistency')
21
+ # @param project_root [String, nil] Optional project root (auto-detected if nil)
22
+ # @param timestamp_formatter [Proc, nil] Optional custom timestamp formatter
23
+ # @return [String] Path to created session directory
24
+ def create_session(gem_name, operation, project_root: nil, timestamp_formatter: nil)
25
+ raise ArgumentError, "gem_name cannot be nil or empty" if gem_name.nil? || gem_name.strip.empty?
26
+ raise ArgumentError, "operation cannot be nil or empty" if operation.nil? || operation.strip.empty?
27
+
28
+ begin
29
+ root = project_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
30
+ timestamp = timestamp_formatter ? timestamp_formatter.call(Time.now) : Time.now.strftime("%Y%m%d-%H%M%S")
31
+ session_name = "#{operation}-#{timestamp}"
32
+ session_dir = File.join(base_cache_path(root, gem_name), session_name)
33
+ FileUtils.mkdir_p(session_dir)
34
+ session_dir
35
+ rescue Errno::ENOENT, Errno::EACCES => e
36
+ raise PromptCacheError, "Failed to create session directory: #{e.message}"
37
+ rescue => e
38
+ raise PromptCacheError, "Unexpected error creating session: #{e.message}"
39
+ end
40
+ end
41
+
42
+ # Save system prompt to session directory
43
+ # @param content [String] Prompt content
44
+ # @param session_dir [String] Session directory path
45
+ # @return [String] Path to saved file
46
+ def save_system_prompt(content, session_dir)
47
+ save_prompt(content, session_dir, "system.prompt.md")
48
+ end
49
+
50
+ # Save user prompt to session directory
51
+ # @param content [String] Prompt content
52
+ # @param session_dir [String] Session directory path
53
+ # @return [String] Path to saved file
54
+ def save_user_prompt(content, session_dir)
55
+ save_prompt(content, session_dir, "user.prompt.md")
56
+ end
57
+
58
+ # Save metadata to session directory
59
+ # @param metadata [Hash] Metadata hash
60
+ # @param session_dir [String] Session directory path
61
+ # @param validate [Boolean] Whether to validate metadata schema (default: true)
62
+ # @return [String] Path to saved file
63
+ def save_metadata(metadata, session_dir, validate: true)
64
+ raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
65
+ raise ArgumentError, "session_dir cannot be nil or empty" if session_dir.nil? || session_dir.strip.empty?
66
+
67
+ validate_metadata(metadata) if validate
68
+
69
+ begin
70
+ metadata_path = File.join(session_dir, "metadata.yml")
71
+ File.write(metadata_path, YAML.dump(metadata))
72
+ metadata_path
73
+ rescue Errno::ENOENT, Errno::EACCES => e
74
+ raise PromptCacheError, "Failed to save metadata to #{metadata_path}: #{e.message}"
75
+ rescue => e
76
+ raise PromptCacheError, "Unexpected error saving metadata: #{e.message}"
77
+ end
78
+ end
79
+
80
+ # Validate metadata schema
81
+ # @param metadata [Hash] Metadata hash to validate
82
+ # @raise [ArgumentError] If required fields are missing
83
+ def validate_metadata(metadata)
84
+ required_fields = %w[timestamp gem operation]
85
+ missing = required_fields - metadata.keys
86
+ unless missing.empty?
87
+ raise ArgumentError, "Missing required metadata fields: #{missing.join(", ")}"
88
+ end
89
+
90
+ # Validate field types
91
+ unless metadata["timestamp"].is_a?(String) || metadata["timestamp"].is_a?(Time)
92
+ raise ArgumentError, "Metadata 'timestamp' must be a String or Time"
93
+ end
94
+
95
+ unless metadata["gem"].is_a?(String) && !metadata["gem"].strip.empty?
96
+ raise ArgumentError, "Metadata 'gem' must be a non-empty String"
97
+ end
98
+
99
+ unless metadata["operation"].is_a?(String) && !metadata["operation"].strip.empty?
100
+ raise ArgumentError, "Metadata 'operation' must be a non-empty String"
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ # Get base cache path for a gem
107
+ # @param project_root [String] Project root path
108
+ # @param gem_name [String] Name of the gem
109
+ # @return [String] Base cache path (.ace-local/{short-gem}/sessions/)
110
+ def base_cache_path(project_root, gem_name)
111
+ short_name = gem_name.to_s.sub(/\Aace-/, "")
112
+ cache_path = File.join(project_root, ".ace-local", short_name, "sessions")
113
+ FileUtils.mkdir_p(cache_path) unless Dir.exist?(cache_path)
114
+ cache_path
115
+ end
116
+
117
+ # Save a prompt file
118
+ # @param content [String] Prompt content
119
+ # @param session_dir [String] Session directory path
120
+ # @param filename [String] Filename for the prompt
121
+ # @return [String] Path to saved file
122
+ def save_prompt(content, session_dir, filename)
123
+ raise ArgumentError, "content cannot be nil" if content.nil?
124
+ raise ArgumentError, "session_dir cannot be nil or empty" if session_dir.nil? || session_dir.strip.empty?
125
+ raise ArgumentError, "filename cannot be nil or empty" if filename.nil? || filename.strip.empty?
126
+
127
+ begin
128
+ prompt_path = File.join(session_dir, filename)
129
+ File.write(prompt_path, content)
130
+ prompt_path
131
+ rescue Errno::ENOENT, Errno::EACCES => e
132
+ raise PromptCacheError, "Failed to save prompt to #{prompt_path}: #{e.message}"
133
+ rescue => e
134
+ raise PromptCacheError, "Unexpected error saving prompt: #{e.message}"
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "pathname"
5
+ require_relative "../models/config_templates"
6
+
7
+ module Ace
8
+ module Core
9
+ class ConfigDiff
10
+ def initialize(global: false, file: nil, one_line: false)
11
+ @global = global
12
+ @file = file
13
+ @one_line = one_line
14
+ @diffs = []
15
+ end
16
+
17
+ def run
18
+ if @file
19
+ diff_file(@file)
20
+ else
21
+ diff_all_configs
22
+ end
23
+
24
+ print_results
25
+ end
26
+
27
+ def diff_gem(gem_name)
28
+ # Normalize gem name (support both "ace-bundle" and "bundle")
29
+ gem_name = gem_name.start_with?("ace-") ? gem_name : "ace-#{gem_name}"
30
+
31
+ unless ConfigTemplates.gem_exists?(gem_name)
32
+ puts "Error: Gem '#{gem_name}' not found or has no example configurations"
33
+ exit 1
34
+ end
35
+
36
+ diff_gem_configs(gem_name)
37
+ print_results
38
+ end
39
+
40
+ private
41
+
42
+ def config_directory
43
+ @global ? File.expand_path("~/.ace") : ".ace"
44
+ end
45
+
46
+ def diff_all_configs
47
+ ConfigTemplates.all_gems.each do |gem_name|
48
+ diff_gem_configs(gem_name)
49
+ end
50
+ end
51
+
52
+ def diff_gem_configs(gem_name)
53
+ source_dir = ConfigTemplates.example_dir_for(gem_name)
54
+
55
+ return unless source_dir && File.exist?(source_dir)
56
+
57
+ Dir.glob("#{source_dir}/**/*").each do |source_file|
58
+ next if File.directory?(source_file)
59
+
60
+ relative_path = Pathname.new(source_file).relative_path_from(Pathname.new(source_dir))
61
+ target_file = File.join(config_directory, relative_path.to_s)
62
+
63
+ compare_files(source_file, target_file, gem_name)
64
+ end
65
+ end
66
+
67
+ def diff_file(file_path)
68
+ # Determine which gem this file belongs to
69
+ if file_path.start_with?(config_directory)
70
+ relative_path = Pathname.new(file_path).relative_path_from(Pathname.new(config_directory))
71
+ parts = relative_path.to_s.split(File::SEPARATOR)
72
+
73
+ if parts.any?
74
+ config_subdir = parts.first
75
+ gem_name = "ace-#{config_subdir}"
76
+
77
+ if ConfigTemplates.gem_exists?(gem_name)
78
+ source_dir = ConfigTemplates.example_dir_for(gem_name)
79
+ relative_file = parts[1..-1].join(File::SEPARATOR)
80
+ source_file = File.join(source_dir, relative_file)
81
+
82
+ if File.exist?(source_file)
83
+ compare_files(source_file, file_path, gem_name)
84
+ else
85
+ puts "No example file found for #{file_path}"
86
+ end
87
+ end
88
+ end
89
+ else
90
+ puts "File #{file_path} is not in a configuration directory"
91
+ end
92
+ end
93
+
94
+ def compare_files(source_file, target_file, gem_name)
95
+ @diffs << if !File.exist?(target_file)
96
+ {
97
+ gem: gem_name,
98
+ file: target_file,
99
+ status: :missing,
100
+ source: source_file
101
+ }
102
+ elsif files_differ?(source_file, target_file)
103
+ {
104
+ gem: gem_name,
105
+ file: target_file,
106
+ status: :different,
107
+ source: source_file,
108
+ diff_output: get_diff_output(source_file, target_file)
109
+ }
110
+ else
111
+ {
112
+ gem: gem_name,
113
+ file: target_file,
114
+ status: :same,
115
+ source: source_file
116
+ }
117
+ end
118
+ end
119
+
120
+ def files_differ?(file1, file2)
121
+ File.read(file1) != File.read(file2)
122
+ rescue
123
+ true
124
+ end
125
+
126
+ def get_diff_output(source_file, target_file)
127
+ # Use system diff command
128
+ output, _status = Open3.capture2("diff", "-u", target_file, source_file)
129
+ output
130
+ rescue
131
+ "Unable to generate diff"
132
+ end
133
+
134
+ def print_results
135
+ if @one_line
136
+ print_one_line_summary
137
+ else
138
+ print_detailed_diffs
139
+ end
140
+ end
141
+
142
+ def print_one_line_summary
143
+ @diffs.each do |diff|
144
+ case diff[:status]
145
+ when :missing
146
+ puts "MISSING: #{diff[:file]}"
147
+ when :different
148
+ puts "CHANGED: #{diff[:file]}"
149
+ when :same
150
+ puts "SAME: #{diff[:file]}" if @verbose
151
+ end
152
+ end
153
+
154
+ puts "\nSummary:"
155
+ puts " Missing: #{@diffs.count { |d| d[:status] == :missing }}"
156
+ puts " Changed: #{@diffs.count { |d| d[:status] == :different }}"
157
+ puts " Same: #{@diffs.count { |d| d[:status] == :same }}"
158
+ end
159
+
160
+ def print_detailed_diffs
161
+ missing = @diffs.select { |d| d[:status] == :missing }
162
+ changed = @diffs.select { |d| d[:status] == :different }
163
+
164
+ if missing.any?
165
+ puts "Missing configuration files:"
166
+ missing.each do |diff|
167
+ puts " #{diff[:file]}"
168
+ puts " -> Example: #{diff[:source]}"
169
+ end
170
+ puts
171
+ end
172
+
173
+ if changed.any?
174
+ puts "Changed configuration files:"
175
+ changed.each do |diff|
176
+ puts "\n#{diff[:file]}:"
177
+ puts diff[:diff_output]
178
+ end
179
+ end
180
+
181
+ if missing.empty? && changed.empty?
182
+ puts "All configuration files match the examples."
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+ require_relative "../models/config_templates"
6
+
7
+ module Ace
8
+ module Core
9
+ class ConfigInitializer
10
+ def initialize(force: false, dry_run: false, global: false, verbose: false)
11
+ @force = force
12
+ @dry_run = dry_run
13
+ @global = global
14
+ @verbose = verbose
15
+ @copied_files = []
16
+ @skipped_files = []
17
+ end
18
+
19
+ def init_all
20
+ puts "Initializing all ace-* gem configurations..." if @verbose
21
+
22
+ ConfigTemplates.all_gems.each do |gem_name|
23
+ init_gem(gem_name)
24
+ end
25
+
26
+ print_summary
27
+ end
28
+
29
+ def init_gem(gem_name)
30
+ gem_name = normalize_gem_name(gem_name)
31
+
32
+ unless ConfigTemplates.gem_exists?(gem_name)
33
+ puts "Warning: No configuration found for #{gem_name}"
34
+ return
35
+ end
36
+
37
+ puts "\nInitializing #{gem_name}..." if @verbose
38
+
39
+ source_dir = ConfigTemplates.example_dir_for(gem_name)
40
+ target_dir = target_directory
41
+
42
+ unless File.exist?(source_dir)
43
+ puts "Warning: No .ace-defaults directory found for #{gem_name}"
44
+ return
45
+ end
46
+
47
+ # Show config docs on first run if config is missing
48
+ show_config_docs_if_needed(gem_name, target_dir)
49
+
50
+ # Copy files from .ace-defaults to .ace
51
+ copy_config_files(source_dir, target_dir, gem_name)
52
+ end
53
+
54
+ private
55
+
56
+ def normalize_gem_name(name)
57
+ # Handle both "ace-core" and "core" formats
58
+ name.start_with?("ace-") ? name : "ace-#{name}"
59
+ end
60
+
61
+ def target_directory
62
+ @global ? File.expand_path("~/.ace") : ".ace"
63
+ end
64
+
65
+ def show_config_docs_if_needed(gem_name, target_dir)
66
+ # Check if any config files exist for this gem
67
+ config_subdir = gem_name.sub("ace-", "")
68
+
69
+ # Look for any existing config files in the expected location
70
+ existing_configs = Dir.glob("#{target_dir}/#{config_subdir}/**/*").reject { |f| File.directory?(f) }
71
+
72
+ # Only show docs if this is the first time (no existing config)
73
+ if existing_configs.empty? && !@dry_run
74
+ docs_file = ConfigTemplates.docs_file_for(gem_name)
75
+ if File.exist?(docs_file)
76
+ puts "\n#{File.read(docs_file)}\n"
77
+ end
78
+ end
79
+ end
80
+
81
+ def copy_config_files(source_dir, target_dir, gem_name)
82
+ Dir.glob("#{source_dir}/**/*").each do |source_file|
83
+ next if File.directory?(source_file)
84
+
85
+ # Calculate relative path from source_dir
86
+ relative_path = Pathname.new(source_file).relative_path_from(Pathname.new(source_dir))
87
+
88
+ # Build target path - files already include their subdirectory
89
+ target_file = File.join(target_dir, relative_path.to_s)
90
+
91
+ copy_file(source_file, target_file)
92
+ end
93
+ end
94
+
95
+ def copy_file(source, target)
96
+ if File.exist?(target) && !@force
97
+ @skipped_files << target
98
+ puts " Skipped: #{target} (already exists)" if @verbose
99
+ return
100
+ end
101
+
102
+ if @dry_run
103
+ puts " Would copy: #{source} -> #{target}"
104
+ else
105
+ FileUtils.mkdir_p(File.dirname(target))
106
+ FileUtils.cp(source, target)
107
+ @copied_files << target
108
+ puts " Copied: #{target}" if @verbose
109
+ end
110
+ end
111
+
112
+ def print_summary
113
+ return if @dry_run
114
+
115
+ puts "\nConfiguration initialization complete:"
116
+ puts " Files copied: #{@copied_files.size}"
117
+ puts " Files skipped: #{@skipped_files.size}"
118
+
119
+ if @skipped_files.any? && !@force
120
+ puts "\nUse --force to overwrite existing files"
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../molecules/env_loader"
4
+ require "ace/support/config"
5
+ require "ace/support/fs"
6
+
7
+ module Ace
8
+ module Core
9
+ module Organisms
10
+ # Complete environment variable management
11
+ class EnvironmentManager
12
+ attr_reader :root_path, :config
13
+
14
+ # Initialize environment manager
15
+ # @param root_path [String] Project root path
16
+ # @param config [Models::Config, nil] Configuration to use
17
+ def initialize(root_path: Dir.pwd, config: nil)
18
+ @root_path = Ace::Support::Fs::Atoms::PathExpander.expand(root_path)
19
+ @config = config || load_config
20
+ end
21
+
22
+ # Load environment variables based on configuration
23
+ # @param overwrite [Boolean] Whether to overwrite existing vars
24
+ # @return [Hash] Variables that were loaded
25
+ def load
26
+ return {} unless should_load_dotenv?
27
+
28
+ dotenv_files = get_dotenv_files
29
+ loaded_vars = {}
30
+
31
+ dotenv_files.each do |file|
32
+ filepath = resolve_dotenv_path(file)
33
+ next unless filepath
34
+
35
+ vars = Molecules::EnvLoader.load_file(filepath)
36
+ loaded_vars.merge!(vars) if vars && !vars.empty?
37
+ end
38
+
39
+ # Set all loaded variables
40
+ Molecules::EnvLoader.set_environment(loaded_vars, overwrite: false)
41
+ end
42
+
43
+ # Save current environment to .env file
44
+ # @param filepath [String] Path to save to
45
+ # @param keys [Array<String>] Specific keys to save (nil = all)
46
+ # @return [Hash] Variables that were saved
47
+ def save(filepath = ".env", keys: nil)
48
+ filepath = File.join(root_path, filepath) unless Ace::Support::Fs::Atoms::PathExpander.absolute?(filepath)
49
+
50
+ vars_to_save = if keys
51
+ ENV.to_h.select { |k, _| keys.include?(k) }
52
+ else
53
+ ENV.to_h
54
+ end
55
+
56
+ Molecules::EnvLoader.save_file(vars_to_save, filepath)
57
+ vars_to_save
58
+ end
59
+
60
+ # Get specific environment variable with fallback
61
+ # @param key [String] Variable name
62
+ # @param default [Object] Default value if not found
63
+ # @return [String, Object] Variable value or default
64
+ def get(key, default = nil)
65
+ ENV.fetch(key, default)
66
+ end
67
+
68
+ # Set environment variable
69
+ # @param key [String] Variable name
70
+ # @param value [String] Variable value
71
+ # @return [String] The value that was set
72
+ def set(key, value)
73
+ ENV[key] = value.to_s
74
+ end
75
+
76
+ # Check if variable exists
77
+ # @param key [String] Variable name
78
+ # @return [Boolean] true if variable exists
79
+ def key?(key)
80
+ ENV.key?(key)
81
+ end
82
+
83
+ # List all .env files that would be loaded
84
+ # @return [Array<String>] Paths to .env files
85
+ def list_dotenv_files
86
+ return [] unless should_load_dotenv?
87
+
88
+ dotenv_files = get_dotenv_files
89
+ dotenv_files.map { |file| resolve_dotenv_path(file) }.compact
90
+ end
91
+
92
+ private
93
+
94
+ # Load configuration
95
+ # @return [Models::Config] Configuration
96
+ def load_config
97
+ resolver = ::Ace::Support::Config.create(
98
+ config_dir: ".ace",
99
+ defaults_dir: ".ace-defaults"
100
+ )
101
+ resolver.resolve
102
+ end
103
+
104
+ # Check if dotenv should be loaded
105
+ # @return [Boolean] true if should load
106
+ def should_load_dotenv?
107
+ config.get("ace", "environment", "load_dotenv") != false
108
+ end
109
+
110
+ # Get list of dotenv files from config
111
+ # @return [Array<String>] Dotenv file names
112
+ def get_dotenv_files
113
+ files = config.get("ace", "environment", "dotenv_files")
114
+ return [".env.local", ".env"] if files.nil?
115
+
116
+ files.is_a?(Array) ? files : [files]
117
+ end
118
+
119
+ # Resolve dotenv file path
120
+ # @param filename [String] Dotenv filename
121
+ # @return [String, nil] Full path if exists
122
+ def resolve_dotenv_path(filename)
123
+ # Try as absolute path first
124
+ if Ace::Support::Fs::Atoms::PathExpander.absolute?(filename)
125
+ expanded = Ace::Support::Fs::Atoms::PathExpander.expand(filename)
126
+ return expanded if File.exist?(expanded)
127
+ end
128
+
129
+ # Try relative to root
130
+ filepath = File.join(root_path, filename)
131
+ return filepath if File.exist?(filepath)
132
+
133
+ # Try in .ace directory
134
+ filepath = File.join(root_path, ".ace", filename)
135
+ return filepath if File.exist?(filepath)
136
+
137
+ nil
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Core
5
+ VERSION = "0.29.0"
6
+ end
7
+ end