sxn 0.2.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.
Files changed (156) hide show
  1. checksums.yaml +7 -0
  2. data/.gem_rbs_collection/addressable/2.8/.rbs_meta.yaml +9 -0
  3. data/.gem_rbs_collection/addressable/2.8/addressable.rbs +62 -0
  4. data/.gem_rbs_collection/async/2.12/.rbs_meta.yaml +9 -0
  5. data/.gem_rbs_collection/async/2.12/async.rbs +119 -0
  6. data/.gem_rbs_collection/async/2.12/kernel.rbs +5 -0
  7. data/.gem_rbs_collection/async/2.12/manifest.yaml +7 -0
  8. data/.gem_rbs_collection/bcrypt/3.1/.rbs_meta.yaml +9 -0
  9. data/.gem_rbs_collection/bcrypt/3.1/bcrypt.rbs +47 -0
  10. data/.gem_rbs_collection/bcrypt/3.1/manifest.yaml +2 -0
  11. data/.gem_rbs_collection/bigdecimal/3.1/.rbs_meta.yaml +9 -0
  12. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal-math.rbs +119 -0
  13. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal.rbs +1630 -0
  14. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
  15. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
  16. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +26 -0
  17. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
  18. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +65 -0
  19. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
  20. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
  21. data/.gem_rbs_collection/diff-lcs/1.5/.rbs_meta.yaml +9 -0
  22. data/.gem_rbs_collection/diff-lcs/1.5/diff-lcs.rbs +11 -0
  23. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
  24. data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
  25. data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
  26. data/.gem_rbs_collection/mini_mime/0.1/.rbs_meta.yaml +9 -0
  27. data/.gem_rbs_collection/mini_mime/0.1/mini_mime.rbs +14 -0
  28. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
  29. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
  30. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
  31. data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
  32. data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
  33. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
  34. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +822 -0
  35. data/.gem_rbs_collection/sqlite3/2.0/.rbs_meta.yaml +9 -0
  36. data/.gem_rbs_collection/sqlite3/2.0/database.rbs +20 -0
  37. data/.gem_rbs_collection/sqlite3/2.0/pragmas.rbs +5 -0
  38. data/.rspec +4 -0
  39. data/.rubocop.yml +121 -0
  40. data/.simplecov +51 -0
  41. data/CHANGELOG.md +49 -0
  42. data/Gemfile +24 -0
  43. data/Gemfile.lock +329 -0
  44. data/LICENSE.txt +21 -0
  45. data/README.md +225 -0
  46. data/Rakefile +54 -0
  47. data/Steepfile +50 -0
  48. data/bin/sxn +6 -0
  49. data/lib/sxn/CLI.rb +275 -0
  50. data/lib/sxn/commands/init.rb +137 -0
  51. data/lib/sxn/commands/projects.rb +350 -0
  52. data/lib/sxn/commands/rules.rb +435 -0
  53. data/lib/sxn/commands/sessions.rb +300 -0
  54. data/lib/sxn/commands/worktrees.rb +416 -0
  55. data/lib/sxn/commands.rb +13 -0
  56. data/lib/sxn/config/config_cache.rb +295 -0
  57. data/lib/sxn/config/config_discovery.rb +242 -0
  58. data/lib/sxn/config/config_validator.rb +562 -0
  59. data/lib/sxn/config.rb +259 -0
  60. data/lib/sxn/core/config_manager.rb +290 -0
  61. data/lib/sxn/core/project_manager.rb +307 -0
  62. data/lib/sxn/core/rules_manager.rb +306 -0
  63. data/lib/sxn/core/session_manager.rb +336 -0
  64. data/lib/sxn/core/worktree_manager.rb +281 -0
  65. data/lib/sxn/core.rb +13 -0
  66. data/lib/sxn/database/errors.rb +29 -0
  67. data/lib/sxn/database/session_database.rb +691 -0
  68. data/lib/sxn/database.rb +24 -0
  69. data/lib/sxn/errors.rb +76 -0
  70. data/lib/sxn/rules/base_rule.rb +367 -0
  71. data/lib/sxn/rules/copy_files_rule.rb +346 -0
  72. data/lib/sxn/rules/errors.rb +28 -0
  73. data/lib/sxn/rules/project_detector.rb +871 -0
  74. data/lib/sxn/rules/rules_engine.rb +485 -0
  75. data/lib/sxn/rules/setup_commands_rule.rb +307 -0
  76. data/lib/sxn/rules/template_rule.rb +262 -0
  77. data/lib/sxn/rules.rb +148 -0
  78. data/lib/sxn/runtime_validations.rb +96 -0
  79. data/lib/sxn/security/secure_command_executor.rb +364 -0
  80. data/lib/sxn/security/secure_file_copier.rb +478 -0
  81. data/lib/sxn/security/secure_path_validator.rb +258 -0
  82. data/lib/sxn/security.rb +15 -0
  83. data/lib/sxn/templates/common/gitignore.liquid +99 -0
  84. data/lib/sxn/templates/common/session-info.md.liquid +58 -0
  85. data/lib/sxn/templates/errors.rb +36 -0
  86. data/lib/sxn/templates/javascript/README.md.liquid +59 -0
  87. data/lib/sxn/templates/javascript/session-info.md.liquid +206 -0
  88. data/lib/sxn/templates/rails/CLAUDE.md.liquid +78 -0
  89. data/lib/sxn/templates/rails/database.yml.liquid +31 -0
  90. data/lib/sxn/templates/rails/session-info.md.liquid +144 -0
  91. data/lib/sxn/templates/template_engine.rb +346 -0
  92. data/lib/sxn/templates/template_processor.rb +279 -0
  93. data/lib/sxn/templates/template_security.rb +410 -0
  94. data/lib/sxn/templates/template_variables.rb +713 -0
  95. data/lib/sxn/templates.rb +28 -0
  96. data/lib/sxn/ui/output.rb +103 -0
  97. data/lib/sxn/ui/progress_bar.rb +91 -0
  98. data/lib/sxn/ui/prompt.rb +116 -0
  99. data/lib/sxn/ui/table.rb +183 -0
  100. data/lib/sxn/ui.rb +12 -0
  101. data/lib/sxn/version.rb +5 -0
  102. data/lib/sxn.rb +63 -0
  103. data/rbs_collection.lock.yaml +180 -0
  104. data/rbs_collection.yaml +39 -0
  105. data/scripts/test.sh +31 -0
  106. data/sig/external/liquid.rbs +116 -0
  107. data/sig/external/thor.rbs +99 -0
  108. data/sig/external/tty.rbs +71 -0
  109. data/sig/sxn/cli.rbs +46 -0
  110. data/sig/sxn/commands/init.rbs +38 -0
  111. data/sig/sxn/commands/projects.rbs +72 -0
  112. data/sig/sxn/commands/rules.rbs +95 -0
  113. data/sig/sxn/commands/sessions.rbs +62 -0
  114. data/sig/sxn/commands/worktrees.rbs +82 -0
  115. data/sig/sxn/commands.rbs +6 -0
  116. data/sig/sxn/config/config_cache.rbs +67 -0
  117. data/sig/sxn/config/config_discovery.rbs +64 -0
  118. data/sig/sxn/config/config_validator.rbs +64 -0
  119. data/sig/sxn/config.rbs +74 -0
  120. data/sig/sxn/core/config_manager.rbs +67 -0
  121. data/sig/sxn/core/project_manager.rbs +52 -0
  122. data/sig/sxn/core/rules_manager.rbs +54 -0
  123. data/sig/sxn/core/session_manager.rbs +59 -0
  124. data/sig/sxn/core/worktree_manager.rbs +50 -0
  125. data/sig/sxn/core.rbs +87 -0
  126. data/sig/sxn/database/errors.rbs +37 -0
  127. data/sig/sxn/database/session_database.rbs +151 -0
  128. data/sig/sxn/database.rbs +83 -0
  129. data/sig/sxn/errors.rbs +89 -0
  130. data/sig/sxn/rules/base_rule.rbs +137 -0
  131. data/sig/sxn/rules/copy_files_rule.rbs +65 -0
  132. data/sig/sxn/rules/errors.rbs +33 -0
  133. data/sig/sxn/rules/project_detector.rbs +115 -0
  134. data/sig/sxn/rules/rules_engine.rbs +118 -0
  135. data/sig/sxn/rules/setup_commands_rule.rbs +60 -0
  136. data/sig/sxn/rules/template_rule.rbs +44 -0
  137. data/sig/sxn/rules.rbs +287 -0
  138. data/sig/sxn/runtime_validations.rbs +16 -0
  139. data/sig/sxn/security/secure_command_executor.rbs +63 -0
  140. data/sig/sxn/security/secure_file_copier.rbs +79 -0
  141. data/sig/sxn/security/secure_path_validator.rbs +30 -0
  142. data/sig/sxn/security.rbs +128 -0
  143. data/sig/sxn/templates/errors.rbs +43 -0
  144. data/sig/sxn/templates/template_engine.rbs +50 -0
  145. data/sig/sxn/templates/template_processor.rbs +44 -0
  146. data/sig/sxn/templates/template_security.rbs +62 -0
  147. data/sig/sxn/templates/template_variables.rbs +103 -0
  148. data/sig/sxn/templates.rbs +104 -0
  149. data/sig/sxn/ui/output.rbs +50 -0
  150. data/sig/sxn/ui/progress_bar.rbs +39 -0
  151. data/sig/sxn/ui/prompt.rbs +38 -0
  152. data/sig/sxn/ui/table.rbs +43 -0
  153. data/sig/sxn/ui.rbs +63 -0
  154. data/sig/sxn/version.rbs +5 -0
  155. data/sig/sxn.rbs +29 -0
  156. metadata +635 -0
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "commands/init"
4
+ require_relative "commands/sessions"
5
+ require_relative "commands/projects"
6
+ require_relative "commands/worktrees"
7
+ require_relative "commands/rules"
8
+
9
+ module Sxn
10
+ # Commands namespace for all CLI command implementations
11
+ module Commands
12
+ end
13
+ end
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "digest"
6
+
7
+ module Sxn
8
+ module Config
9
+ # Caches discovered configurations with TTL and file change invalidation
10
+ #
11
+ # Features:
12
+ # - Time-based cache expiration (TTL)
13
+ # - File modification time checking for cache invalidation
14
+ # - Atomic cache file operations
15
+ # - Cache storage in .sxn/.cache/config.json
16
+ class ConfigCache
17
+ CACHE_DIR = ".sxn/.cache"
18
+ CACHE_FILE = "config.json"
19
+ DEFAULT_TTL = 300 # 5 minutes in seconds
20
+
21
+ attr_reader :cache_dir, :cache_file_path, :ttl
22
+
23
+ def initialize(cache_dir: nil, ttl: DEFAULT_TTL)
24
+ @cache_dir = cache_dir || File.join(Dir.pwd, CACHE_DIR)
25
+ @cache_file_path = File.join(@cache_dir, CACHE_FILE)
26
+ @ttl = ttl
27
+ @write_mutex = Mutex.new
28
+ ensure_cache_directory
29
+ end
30
+
31
+ # Get cached configuration or nil if invalid/missing
32
+ # @param config_files [Array<String>] List of config file paths to check
33
+ # @return [Hash, nil] Cached configuration or nil
34
+ def get(config_files)
35
+ return nil unless cache_exists?
36
+
37
+ cache_data = load_cache
38
+ return nil unless cache_data
39
+
40
+ return nil unless cache_valid?(cache_data, config_files)
41
+
42
+ cache_data["config"]
43
+ rescue StandardError => e
44
+ warn "Warning: Failed to load cache: #{e.message}"
45
+ nil
46
+ end
47
+
48
+ # Store configuration in cache
49
+ # @param config [Hash] Configuration to cache
50
+ # @param config_files [Array<String>] List of config file paths
51
+ # @return [Boolean] Success status
52
+ def set(config, config_files)
53
+ cache_data = {
54
+ "config" => config,
55
+ "cached_at" => Time.now.to_f,
56
+ "config_files" => build_file_metadata(config_files),
57
+ "cache_version" => 1
58
+ }
59
+
60
+ save_cache(cache_data)
61
+ true
62
+ rescue StandardError => e
63
+ warn "Warning: Failed to save cache: #{e.message}"
64
+ false
65
+ end
66
+
67
+ # Invalidate the cache by removing the cache file
68
+ # @return [Boolean] Success status
69
+ def invalidate
70
+ return true unless cache_exists?
71
+
72
+ File.delete(cache_file_path)
73
+ true
74
+ rescue StandardError => e
75
+ warn "Warning: Failed to invalidate cache: #{e.message}"
76
+ false
77
+ end
78
+
79
+ # Check if cache is valid without loading the full configuration
80
+ # @param config_files [Array<String>] List of config file paths to check
81
+ # @return [Boolean] True if cache is valid
82
+ def valid?(config_files)
83
+ return false unless cache_exists?
84
+
85
+ cache_data = load_cache
86
+ return false unless cache_data
87
+
88
+ cache_valid?(cache_data, config_files)
89
+ rescue StandardError
90
+ false
91
+ end
92
+
93
+ # Get cache statistics
94
+ # @param config_files [Array<String>] List of config file paths to check validity against
95
+ # @return [Hash] Cache statistics
96
+ def stats(config_files = [])
97
+ return { exists: false, valid: false } unless cache_exists?
98
+
99
+ cache_data = load_cache
100
+ return { exists: true, valid: false, invalid: true } unless cache_data
101
+
102
+ is_valid = cache_valid?(cache_data, config_files || [])
103
+
104
+ {
105
+ exists: true,
106
+ valid: is_valid,
107
+ cached_at: Time.at(cache_data["cached_at"]),
108
+ age_seconds: Time.now.to_f - cache_data["cached_at"],
109
+ file_count: cache_data["config_files"]&.length || 0,
110
+ cache_version: cache_data["cache_version"]
111
+ }
112
+ rescue StandardError
113
+ { exists: true, valid: false, invalid: true, error: true }
114
+ end
115
+
116
+ private
117
+
118
+ # Ensure cache directory exists
119
+ def ensure_cache_directory
120
+ return if Dir.exist?(cache_dir)
121
+
122
+ # Thread-safe directory creation
123
+ FileUtils.mkdir_p(cache_dir)
124
+ rescue SystemCallError
125
+ # Directory might have been created by another thread/process
126
+ # Only re-raise if directory still doesn't exist
127
+ raise unless Dir.exist?(cache_dir)
128
+ end
129
+
130
+ # Check if cache file exists
131
+ # @return [Boolean] True if cache file exists
132
+ def cache_exists?
133
+ File.exist?(cache_file_path)
134
+ end
135
+
136
+ # Load cache data from file
137
+ # @return [Hash, nil] Cache data or nil if invalid
138
+ def load_cache
139
+ return nil unless cache_exists?
140
+
141
+ content = File.read(cache_file_path)
142
+ JSON.parse(content)
143
+ rescue JSON::ParserError => e
144
+ warn "Warning: Invalid cache file JSON: #{e.message}"
145
+ nil
146
+ end
147
+
148
+ # Save cache data to file atomically
149
+ # @param cache_data [Hash] Cache data to save
150
+ def save_cache(cache_data)
151
+ @write_mutex.synchronize do
152
+ # Ensure cache directory exists before any file operations
153
+ ensure_cache_directory
154
+
155
+ # Use a more unique temp file name to avoid collisions
156
+ temp_file = "#{cache_file_path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
157
+
158
+ begin
159
+ File.write(temp_file, JSON.pretty_generate(cache_data))
160
+ rescue SystemCallError => e
161
+ # Directory might have been removed, recreate and retry once
162
+ ensure_cache_directory
163
+ begin
164
+ File.write(temp_file, JSON.pretty_generate(cache_data))
165
+ rescue SystemCallError
166
+ # If still failing, give up gracefully
167
+ warn "Warning: Failed to write cache: #{e.message}"
168
+ return false
169
+ end
170
+ end
171
+
172
+ # Retry logic for rename operation in case of race conditions
173
+ retries = 0
174
+ begin
175
+ # Ensure the cache directory still exists before rename
176
+ ensure_cache_directory
177
+
178
+ # Use atomic rename with proper error handling
179
+ File.rename(temp_file, cache_file_path)
180
+ rescue Errno::ENOENT
181
+ retries += 1
182
+ return false unless retries <= 3
183
+
184
+ # Directory or temp file might have issues, recreate and retry
185
+ ensure_cache_directory
186
+
187
+ # If temp file is missing, recreate it
188
+ unless File.exist?(temp_file)
189
+ begin
190
+ File.write(temp_file, JSON.pretty_generate(cache_data))
191
+ rescue SystemCallError
192
+ # Can't recreate, give up
193
+ return false
194
+ end
195
+ end
196
+
197
+ retry
198
+
199
+ # Give up after 3 retries, but don't crash - caching is optional
200
+ # Don't warn here as it's expected in concurrent scenarios
201
+ rescue SystemCallError
202
+ # Handle other system errors gracefully - don't warn as it's expected
203
+ return false
204
+ ensure
205
+ # Clean up temp file if it exists
206
+ FileUtils.rm_f(temp_file) if temp_file && File.exist?(temp_file)
207
+ end
208
+
209
+ true
210
+ end
211
+ end
212
+
213
+ # Check if cache is still valid
214
+ # @param cache_data [Hash] Loaded cache data
215
+ # @param config_files [Array<String>] Current config file paths
216
+ # @return [Boolean] True if cache is valid
217
+ def cache_valid?(cache_data, config_files)
218
+ # Check TTL expiration
219
+ return false if ttl_expired?(cache_data)
220
+
221
+ # Check if any config files have changed
222
+ return false if files_changed?(cache_data, config_files)
223
+
224
+ true
225
+ end
226
+
227
+ # Check if cache has expired based on TTL
228
+ # @param cache_data [Hash] Cache data
229
+ # @return [Boolean] True if cache has expired
230
+ def ttl_expired?(cache_data)
231
+ cached_at = cache_data["cached_at"]
232
+ return true unless cached_at
233
+
234
+ Time.now.to_f - cached_at > ttl
235
+ end
236
+
237
+ # Check if any config files have changed
238
+ # @param cache_data [Hash] Cache data
239
+ # @param config_files [Array<String>] Current config file paths
240
+ # @return [Boolean] True if files have changed
241
+ def files_changed?(cache_data, config_files)
242
+ cached_files = cache_data["config_files"] || {}
243
+ current_files = build_file_metadata(config_files)
244
+
245
+ # Quick check: different number of files
246
+ return true if cached_files.keys.sort != current_files.keys.sort
247
+
248
+ # Check each file's metadata
249
+ current_files.each do |file_path, metadata|
250
+ cached_metadata = cached_files[file_path]
251
+ return true unless cached_metadata
252
+
253
+ # Check if mtime or size changed
254
+ return true if metadata["mtime"] != cached_metadata["mtime"]
255
+ return true if metadata["size"] != cached_metadata["size"]
256
+
257
+ # Always check checksum for content changes (most reliable method)
258
+ return true if metadata["checksum"] != cached_metadata["checksum"]
259
+ end
260
+
261
+ false
262
+ end
263
+
264
+ # Build metadata for config files
265
+ # @param config_files [Array<String>] List of config file paths
266
+ # @return [Hash] File metadata hash
267
+ def build_file_metadata(config_files)
268
+ metadata = {}
269
+
270
+ config_files.each do |file_path|
271
+ next unless File.exist?(file_path)
272
+
273
+ stat = File.stat(file_path)
274
+ metadata[file_path] = {
275
+ "mtime" => stat.mtime.to_f,
276
+ "size" => stat.size,
277
+ "checksum" => file_checksum(file_path)
278
+ }
279
+ end
280
+
281
+ metadata
282
+ end
283
+
284
+ # Calculate file checksum for additional validation
285
+ # @param file_path [String] Path to file
286
+ # @return [String] SHA256 checksum
287
+ def file_checksum(file_path)
288
+ Digest::SHA256.file(file_path).hexdigest
289
+ rescue StandardError
290
+ # If we can't read the file, use a placeholder
291
+ "unreadable"
292
+ end
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+
6
+ module Sxn
7
+ module Config
8
+ # Handles hierarchical configuration discovery and loading
9
+ #
10
+ # Configuration precedence (highest to lowest):
11
+ # 1. Command-line flags
12
+ # 2. Environment variables (SXN_*)
13
+ # 3. Local project config (.sxn/config.yml)
14
+ # 4. Workspace config (.sxn-workspace/config.yml)
15
+ # 5. Global user config (~/.sxn/config.yml)
16
+ # 6. System defaults
17
+ class ConfigDiscovery
18
+ CONFIG_FILE_NAME = "config.yml"
19
+ LOCAL_CONFIG_DIR = ".sxn"
20
+ WORKSPACE_CONFIG_DIR = ".sxn-workspace"
21
+ GLOBAL_CONFIG_DIR = File.expand_path("~/.sxn")
22
+ ENV_PREFIX = "SXN_"
23
+
24
+ attr_reader :start_directory
25
+
26
+ def initialize(start_directory = Dir.pwd)
27
+ @start_directory = Pathname.new(start_directory).expand_path
28
+ end
29
+
30
+ # Discover and load configuration from all sources
31
+ # @param cli_options [Hash] Command-line options
32
+ # @return [Hash] Merged configuration
33
+ def discover_config(cli_options = {})
34
+ config_sources = load_all_configs
35
+ merge_configs(config_sources, cli_options)
36
+ end
37
+
38
+ # Find all configuration files in the hierarchy
39
+ # @return [Array<String>] Paths to configuration files
40
+ def find_config_files
41
+ config_files = []
42
+
43
+ # Local project config (.sxn/config.yml)
44
+ local_config = find_local_config
45
+ config_files << local_config if local_config
46
+
47
+ # Workspace config (.sxn-workspace/config.yml)
48
+ workspace_config = find_workspace_config
49
+ config_files << workspace_config if workspace_config
50
+
51
+ # Global user config (~/.sxn/config.yml)
52
+ global_config = find_global_config
53
+ config_files << global_config if global_config
54
+
55
+ config_files
56
+ end
57
+
58
+ private
59
+
60
+ # Load configurations from all sources
61
+ # @return [Hash] Hash of config sources
62
+ def load_all_configs
63
+ {
64
+ system_defaults: load_system_defaults,
65
+ global_config: load_global_config,
66
+ workspace_config: load_workspace_config,
67
+ local_config: load_local_config,
68
+ env_config: load_env_config
69
+ }
70
+ end
71
+
72
+ # Find local project config by walking up directory tree
73
+ # @return [String, nil] Path to local config file
74
+ def find_local_config
75
+ current_dir = start_directory
76
+
77
+ loop do
78
+ config_path = current_dir.join(LOCAL_CONFIG_DIR, CONFIG_FILE_NAME)
79
+ return config_path.to_s if config_path.exist?
80
+
81
+ parent = current_dir.parent
82
+ break if parent == current_dir # Reached filesystem root
83
+
84
+ current_dir = parent
85
+ end
86
+
87
+ nil
88
+ end
89
+
90
+ # Find workspace config by walking up directory tree
91
+ # @return [String, nil] Path to workspace config file
92
+ def find_workspace_config
93
+ current_dir = start_directory
94
+
95
+ loop do
96
+ config_path = current_dir.join(WORKSPACE_CONFIG_DIR, CONFIG_FILE_NAME)
97
+ return config_path.to_s if config_path.exist?
98
+
99
+ parent = current_dir.parent
100
+ break if parent == current_dir # Reached filesystem root
101
+
102
+ current_dir = parent
103
+ end
104
+
105
+ nil
106
+ end
107
+
108
+ # Find global user config
109
+ # @return [String, nil] Path to global config file
110
+ def find_global_config
111
+ global_config_path = File.join(GLOBAL_CONFIG_DIR, CONFIG_FILE_NAME)
112
+ File.exist?(global_config_path) ? global_config_path : nil
113
+ end
114
+
115
+ # Load system default configuration
116
+ # @return [Hash] Default configuration
117
+ def load_system_defaults
118
+ {
119
+ "version" => 1,
120
+ "sessions_folder" => ".sessions",
121
+ "current_session" => nil,
122
+ "projects" => {},
123
+ "settings" => {
124
+ "auto_cleanup" => true,
125
+ "max_sessions" => 10,
126
+ "worktree_cleanup_days" => 30,
127
+ "default_rules" => {
128
+ "templates" => []
129
+ }
130
+ }
131
+ }
132
+ end
133
+
134
+ # Load global user configuration
135
+ # @return [Hash] Global configuration
136
+ def load_global_config
137
+ config_path = find_global_config
138
+ return {} unless config_path
139
+
140
+ load_yaml_file(config_path)
141
+ rescue StandardError => e
142
+ warn "Warning: Failed to load global config #{config_path}: #{e.message}"
143
+ {}
144
+ end
145
+
146
+ # Load workspace configuration
147
+ # @return [Hash] Workspace configuration
148
+ def load_workspace_config
149
+ config_path = find_workspace_config
150
+ return {} unless config_path
151
+
152
+ load_yaml_file(config_path)
153
+ rescue StandardError => e
154
+ warn "Warning: Failed to load workspace config #{config_path}: #{e.message}"
155
+ {}
156
+ end
157
+
158
+ # Load local project configuration
159
+ # @return [Hash] Local configuration
160
+ def load_local_config
161
+ config_path = find_local_config
162
+ return {} unless config_path
163
+
164
+ load_yaml_file(config_path)
165
+ rescue StandardError => e
166
+ warn "Warning: Failed to load local config #{config_path}: #{e.message}"
167
+ {}
168
+ end
169
+
170
+ # Load environment variable configuration
171
+ # @return [Hash] Environment configuration
172
+ def load_env_config
173
+ env_config = {}
174
+
175
+ ENV.each do |key, value|
176
+ next unless key.start_with?(ENV_PREFIX)
177
+
178
+ # Convert SXN_SESSIONS_FOLDER to sessions_folder
179
+ config_key = key[ENV_PREFIX.length..].downcase
180
+
181
+ # Parse boolean values
182
+ parsed_value = case value.downcase
183
+ when "true" then true
184
+ when "false" then false
185
+ else value
186
+ end
187
+
188
+ env_config[config_key] = parsed_value
189
+ end
190
+
191
+ env_config
192
+ end
193
+
194
+ # Load and parse YAML file safely
195
+ # @param file_path [String] Path to YAML file
196
+ # @return [Hash] Parsed YAML content
197
+ def load_yaml_file(file_path)
198
+ content = File.read(file_path)
199
+ YAML.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: false) || {}
200
+ rescue Psych::SyntaxError => e
201
+ raise ConfigurationError, "Invalid YAML in #{file_path}: #{e.message}"
202
+ rescue StandardError => e
203
+ raise ConfigurationError, "Failed to load config file #{file_path}: #{e.message}"
204
+ end
205
+
206
+ # Merge configurations with proper precedence
207
+ # @param configs [Hash] Hash of configuration sources
208
+ # @param cli_options [Hash] Command-line options
209
+ # @return [Hash] Merged configuration
210
+ def merge_configs(configs, cli_options)
211
+ # Start with system defaults and merge up the precedence chain
212
+ result = (configs[:system_defaults] || {}).dup
213
+
214
+ # Deep merge each config level, skipping nil values
215
+ deep_merge!(result, configs[:global_config]) if configs[:global_config]
216
+ deep_merge!(result, configs[:workspace_config]) if configs[:workspace_config]
217
+ deep_merge!(result, configs[:local_config]) if configs[:local_config]
218
+ deep_merge!(result, configs[:env_config]) if configs[:env_config]
219
+ deep_merge!(result, cli_options)
220
+
221
+ result
222
+ end
223
+
224
+ # Deep merge configuration hashes
225
+ # @param target [Hash] Target hash to merge into
226
+ # @param source [Hash] Source hash to merge from
227
+ def deep_merge!(target, source)
228
+ return target unless source.is_a?(Hash)
229
+
230
+ source.each do |key, value|
231
+ if target[key].is_a?(Hash) && value.is_a?(Hash)
232
+ deep_merge!(target[key], value)
233
+ else
234
+ target[key] = value
235
+ end
236
+ end
237
+
238
+ target
239
+ end
240
+ end
241
+ end
242
+ end