ace-support-nav 0.25.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/nav/config.yml +33 -0
  3. data/.ace-defaults/nav/protocols/guide-sources/ace-support-nav.yml +7 -0
  4. data/.ace-defaults/nav/protocols/guide.yml +69 -0
  5. data/.ace-defaults/nav/protocols/prompt.yml +39 -0
  6. data/.ace-defaults/nav/protocols/skill-sources/ace-support-nav.yml +19 -0
  7. data/.ace-defaults/nav/protocols/skill.yml +22 -0
  8. data/.ace-defaults/nav/protocols/tmpl-sources/ace-support-nav.yml +7 -0
  9. data/.ace-defaults/nav/protocols/tmpl.yml +55 -0
  10. data/.ace-defaults/nav/protocols/wfi-sources/ace-support-nav.yml +7 -0
  11. data/.ace-defaults/nav/protocols/wfi.yml +61 -0
  12. data/CHANGELOG.md +231 -0
  13. data/LICENSE +21 -0
  14. data/README.md +48 -0
  15. data/Rakefile +12 -0
  16. data/docs/demo/ace-support-nav-getting-started.gif +0 -0
  17. data/docs/demo/ace-support-nav-getting-started.tape.yml +28 -0
  18. data/exe/ace-nav +49 -0
  19. data/handbook/workflow-instructions/test.wfi.md +14 -0
  20. data/lib/ace/support/nav/atoms/extension_inferrer.rb +134 -0
  21. data/lib/ace/support/nav/atoms/gem_resolver.rb +59 -0
  22. data/lib/ace/support/nav/atoms/path_normalizer.rb +52 -0
  23. data/lib/ace/support/nav/atoms/uri_parser.rb +62 -0
  24. data/lib/ace/support/nav/cli/commands/create.rb +114 -0
  25. data/lib/ace/support/nav/cli/commands/list.rb +122 -0
  26. data/lib/ace/support/nav/cli/commands/resolve.rb +187 -0
  27. data/lib/ace/support/nav/cli/commands/sources.rb +112 -0
  28. data/lib/ace/support/nav/cli.rb +66 -0
  29. data/lib/ace/support/nav/models/handbook_source.rb +73 -0
  30. data/lib/ace/support/nav/models/protocol_source.rb +104 -0
  31. data/lib/ace/support/nav/models/resource.rb +46 -0
  32. data/lib/ace/support/nav/models/resource_uri.rb +78 -0
  33. data/lib/ace/support/nav/molecules/config_loader.rb +275 -0
  34. data/lib/ace/support/nav/molecules/handbook_scanner.rb +204 -0
  35. data/lib/ace/support/nav/molecules/protocol_scanner.rb +434 -0
  36. data/lib/ace/support/nav/molecules/resource_resolver.rb +134 -0
  37. data/lib/ace/support/nav/molecules/source_registry.rb +133 -0
  38. data/lib/ace/support/nav/organisms/command_delegator.rb +122 -0
  39. data/lib/ace/support/nav/organisms/navigation_engine.rb +180 -0
  40. data/lib/ace/support/nav/version.rb +9 -0
  41. data/lib/ace/support/nav.rb +104 -0
  42. metadata +228 -0
@@ -0,0 +1,434 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/gem_resolver"
4
+ require_relative "../atoms/path_normalizer"
5
+ require_relative "../atoms/extension_inferrer"
6
+ require_relative "../models/handbook_source"
7
+ require_relative "config_loader"
8
+
9
+ module Ace
10
+ module Support
11
+ module Nav
12
+ module Molecules
13
+ # Scans for protocol resources using registered sources
14
+ class ProtocolScanner
15
+ attr_reader :config_loader
16
+
17
+ def initialize(gem_resolver: nil, path_normalizer: nil, config_loader: nil, extension_inferrer: nil)
18
+ @gem_resolver = gem_resolver || Atoms::GemResolver.new
19
+ @path_normalizer = path_normalizer || Atoms::PathNormalizer.new
20
+ @config_loader = config_loader || ConfigLoader.new
21
+ # extension_inferrer param kept for backwards compatibility but ignored
22
+ # ExtensionInferrer now uses class methods
23
+ @extension_inference_enabled = nil
24
+ end
25
+
26
+ # Get all sources for a protocol
27
+ def sources_for_protocol(protocol)
28
+ @config_loader.sources_for_protocol(protocol)
29
+ end
30
+
31
+ # Find resources in all sources for a protocol
32
+ def find_resources(protocol, pattern = "*")
33
+ sources = sources_for_protocol(protocol)
34
+ protocol_config = @config_loader.load_protocol_config(protocol)
35
+
36
+ resources = []
37
+
38
+ sources.each do |source|
39
+ next unless source.exists?
40
+
41
+ resources.concat(find_resources_in_source_internal(source, protocol_config, pattern))
42
+ end
43
+
44
+ resources
45
+ end
46
+
47
+ # Find resources in a specific source (internal implementation)
48
+ def find_resources_in_source_internal(source, protocol_config, pattern = "*")
49
+ # Try exact match first
50
+ resources = find_resources_with_extensions(source, protocol_config, pattern)
51
+
52
+ # If no results and extension inference is enabled, try with inferred extensions
53
+ if resources.empty? && extension_inference_enabled? && !pattern.include?("/") && pattern != "*"
54
+ resources = find_resources_with_inference(source, protocol_config, pattern)
55
+ end
56
+
57
+ resources
58
+ end
59
+
60
+ # Find resources using pattern and extensions (original logic)
61
+ def find_resources_with_extensions(source, protocol_config, pattern = "*")
62
+ # Handle both ProtocolSource and HandbookSource objects
63
+ if source.respond_to?(:full_path)
64
+ return [] unless source.exists?
65
+ search_path = source.full_path
66
+ else
67
+ # Legacy HandbookSource
68
+ return [] unless source.exists?
69
+ search_path = source.handbook_path
70
+ end
71
+
72
+ # Get extensions from protocol config
73
+ extensions = protocol_config["extensions"] || []
74
+
75
+ resources = []
76
+
77
+ # Check if pattern contains directory structure
78
+ if pattern.include?("/")
79
+ # Handle subdirectory patterns
80
+ if pattern.end_with?("/")
81
+ # Pattern like "base/" has two interpretations:
82
+ # 1. Files in a subdirectory named "base"
83
+ # 2. Files that start with "base" prefix
84
+ prefix = pattern.chomp("/")
85
+
86
+ # First, try as a subdirectory
87
+ subdir_path = File.join(search_path, prefix)
88
+ has_subdir = Dir.exist?(subdir_path)
89
+
90
+ if has_subdir
91
+ # Subdirectory exists, list files in it
92
+ found_subdir_paths = Set.new # Track paths to avoid duplicates
93
+
94
+ if extensions.empty?
95
+ # Match any file in the subdirectory
96
+ glob_pattern = File.join(subdir_path, "*")
97
+ glob_pattern_nested = File.join(subdir_path, "**", "*")
98
+
99
+ [glob_pattern, glob_pattern_nested].each do |gp|
100
+ Dir.glob(gp).each do |file_path|
101
+ next unless File.file?(file_path)
102
+ next if found_subdir_paths.include?(file_path) # Skip duplicates
103
+ found_subdir_paths.add(file_path)
104
+ resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
105
+ end
106
+ end
107
+ else
108
+ # Match files with specified extensions in the subdirectory
109
+ extensions.each do |ext|
110
+ glob_pattern = File.join(subdir_path, "*#{ext}")
111
+ glob_pattern_nested = File.join(subdir_path, "**", "*#{ext}")
112
+
113
+ [glob_pattern, glob_pattern_nested].each do |gp|
114
+ Dir.glob(gp).each do |file_path|
115
+ next unless File.file?(file_path)
116
+ next if found_subdir_paths.include?(file_path) # Skip duplicates
117
+ found_subdir_paths.add(file_path)
118
+ resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+
125
+ # Also try as a prefix pattern (files starting with prefix)
126
+ found_paths = Set.new # Track paths to avoid duplicates
127
+
128
+ if extensions.empty?
129
+ # Match files starting with prefix
130
+ glob_patterns = [
131
+ File.join(search_path, "#{prefix}*"),
132
+ File.join(search_path, "**", "#{prefix}*")
133
+ ]
134
+
135
+ glob_patterns.each do |gp|
136
+ Dir.glob(gp).each do |file_path|
137
+ next unless File.file?(file_path)
138
+ next if found_paths.include?(file_path) # Skip duplicates
139
+ found_paths.add(file_path)
140
+ resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
141
+ end
142
+ end
143
+ else
144
+ # Match files with specified extensions starting with prefix
145
+ extensions.each do |ext|
146
+ glob_patterns = [
147
+ File.join(search_path, "#{prefix}*#{ext}"),
148
+ File.join(search_path, "**", "#{prefix}*#{ext}")
149
+ ]
150
+
151
+ glob_patterns.each do |gp|
152
+ Dir.glob(gp).each do |file_path|
153
+ next unless File.file?(file_path)
154
+ next if found_paths.include?(file_path) # Skip duplicates
155
+ found_paths.add(file_path)
156
+ resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
157
+ end
158
+ end
159
+ end
160
+ end
161
+ elsif extensions.empty?
162
+ # Pattern like "base/*" or "base/something" - use as-is but handle properly
163
+ glob_pattern = File.join(search_path, pattern)
164
+ glob_pattern += "*" unless pattern.end_with?("*") || pattern.include?("*")
165
+
166
+ Dir.glob(glob_pattern).each do |file_path|
167
+ next unless File.file?(file_path)
168
+ resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
169
+ end
170
+ else
171
+ extensions.each do |ext|
172
+ glob_pattern = if pattern.end_with?(ext)
173
+ File.join(search_path, pattern)
174
+ else
175
+ File.join(search_path, "#{pattern}#{ext}")
176
+ end
177
+
178
+ Dir.glob(glob_pattern).each do |file_path|
179
+ next unless File.file?(file_path)
180
+ resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
181
+ end
182
+ end
183
+ end
184
+ elsif extensions.empty?
185
+ # Original behavior for patterns without directory structure
186
+ glob_pattern = File.join(search_path, "**", pattern)
187
+ glob_pattern += "*" unless pattern.end_with?("*")
188
+
189
+ Dir.glob(glob_pattern).each do |file_path|
190
+ next unless File.file?(file_path)
191
+
192
+ resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
193
+ end
194
+ # If no extensions specified, match any file
195
+ else
196
+ # Match files with specified extensions
197
+ extensions.each do |ext|
198
+ # Check if pattern already ends with this extension
199
+ glob_pattern = if pattern.end_with?(ext)
200
+ # Pattern already has extension, search as-is
201
+ File.join(search_path, "**", pattern)
202
+ else
203
+ # Append extension to pattern
204
+ File.join(search_path, "**", "#{pattern}#{ext}")
205
+ end
206
+
207
+ Dir.glob(glob_pattern).each do |file_path|
208
+ next unless File.file?(file_path)
209
+
210
+ resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
211
+ end
212
+ end
213
+ end
214
+
215
+ resources
216
+ end
217
+
218
+ # Find resources using extension inference when exact match fails
219
+ def find_resources_with_inference(source, protocol_config, pattern)
220
+ # Handle both ProtocolSource and HandbookSource objects
221
+ if source.respond_to?(:full_path)
222
+ return [] unless source.exists?
223
+ search_path = source.full_path
224
+ else
225
+ return [] unless source.exists?
226
+ search_path = source.handbook_path
227
+ end
228
+
229
+ # Get configuration for extension inference
230
+ settings = @config_loader.load_settings
231
+ inference_config = settings["extension_inference"] || {}
232
+ enabled = inference_config["enabled"] != false # Default true
233
+ fallback_order = inference_config["fallback_order"]
234
+
235
+ # Get protocol extensions
236
+ protocol_extensions = protocol_config["extensions"] || []
237
+ inferred_extensions = protocol_config["inferred_extensions"] || protocol_extensions
238
+
239
+ # Generate candidate patterns using extension inferrer
240
+ candidates = Atoms::ExtensionInferrer.infer_extensions(
241
+ pattern,
242
+ protocol_extensions: inferred_extensions,
243
+ enabled: enabled,
244
+ fallback_order: fallback_order
245
+ )
246
+
247
+ resources = []
248
+ found_paths = Set.new # Track paths to avoid duplicates
249
+
250
+ # Try each candidate pattern in order
251
+ candidates.each do |candidate|
252
+ # For inference, we need to allow additional extensions after the inferred one
253
+ # e.g., "mydoc.cst" should match "mydoc.cst.md"
254
+ # Use brace expansion for tighter matching: exact match or with extension
255
+ glob_pattern = File.join(search_path, "**", candidate + "{,.*}")
256
+
257
+ Dir.glob(glob_pattern).each do |file_path|
258
+ next unless File.file?(file_path)
259
+ next if found_paths.include?(file_path)
260
+
261
+ # Only match if basename equals candidate or has candidate as prefix with dot separator
262
+ # This prevents "multi-ext.g" from matching "multi-ext.guide.md"
263
+ basename = File.basename(file_path)
264
+ basename_candidate = File.basename(candidate)
265
+ next unless basename == basename_candidate ||
266
+ basename.start_with?(basename_candidate + ".")
267
+
268
+ found_paths.add(file_path)
269
+ resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
270
+ end
271
+
272
+ # Stop at first match (DWIM: return first successful inference)
273
+ break if resources.any?
274
+ end
275
+
276
+ resources
277
+ end
278
+
279
+ # Check if extension inference is enabled in settings (cached)
280
+ def extension_inference_enabled?
281
+ return @extension_inference_enabled unless @extension_inference_enabled.nil?
282
+
283
+ settings = @config_loader.load_settings
284
+ inference_config = settings["extension_inference"] || {}
285
+ @extension_inference_enabled = inference_config["enabled"] != false # Default true
286
+ end
287
+
288
+ # Reset extension inference cache (for testing)
289
+ def reset_extension_inference_cache!
290
+ @extension_inference_enabled = nil
291
+ end
292
+
293
+ # Legacy wrapper method for HandbookScanner compatibility
294
+ def find_resources_in_source(source, protocol, pattern = "*")
295
+ # If second param is a string (protocol name), load its config
296
+ if protocol.is_a?(String)
297
+ protocol_config = @config_loader.load_protocol_config(protocol)
298
+ find_resources_in_source_internal(source, protocol_config, pattern)
299
+ else
300
+ # Already a protocol config
301
+ find_resources_in_source_internal(source, protocol, pattern)
302
+ end
303
+ end
304
+
305
+ # Legacy method for compatibility - get all sources across all protocols
306
+ def scan_all_sources
307
+ sources = []
308
+ protocols = @config_loader.valid_protocols
309
+
310
+ protocols.each do |protocol|
311
+ protocol_sources = sources_for_protocol(protocol)
312
+
313
+ # Convert to legacy HandbookSource format for compatibility
314
+ protocol_sources.each do |source|
315
+ # The path already points to the handbook directory
316
+ # HandbookSource will append /handbook if needed
317
+ base_path = source.full_path
318
+
319
+ # Remove /handbook from the path if present since HandbookSource adds it
320
+ if base_path.end_with?("/handbook")
321
+ base_path = File.dirname(base_path)
322
+ end
323
+
324
+ sources << Models::HandbookSource.new(
325
+ name: source.name,
326
+ path: base_path,
327
+ alias_name: "@#{source.name}",
328
+ type: source.type.to_sym,
329
+ priority: source.priority
330
+ )
331
+ end
332
+ end
333
+
334
+ # Remove duplicates by alias_name
335
+ sources.uniq { |s| s.alias_name }
336
+ end
337
+
338
+ # Legacy method - scan source by alias
339
+ def scan_source_by_alias(alias_name)
340
+ # Remove @ prefix if present
341
+ name = alias_name.start_with?("@") ? alias_name[1..] : alias_name
342
+
343
+ # Handle special aliases
344
+ case name
345
+ when "project", "local"
346
+ return scan_project_source
347
+ when "user", "global"
348
+ return scan_user_source
349
+ end
350
+
351
+ # Find in registered sources
352
+ protocols = @config_loader.valid_protocols
353
+
354
+ protocols.each do |protocol|
355
+ source = sources_for_protocol(protocol).find { |s| s.name == name }
356
+ if source
357
+ return Models::HandbookSource.new(
358
+ name: source.name,
359
+ path: File.dirname(source.full_path),
360
+ alias_name: "@#{source.name}",
361
+ type: source.type.to_sym,
362
+ priority: source.priority
363
+ )
364
+ end
365
+ end
366
+
367
+ nil
368
+ end
369
+
370
+ private
371
+
372
+ def create_resource_info(file_path, search_path, source, protocol)
373
+ # Ensure search_path ends with a separator for proper substitution
374
+ normalized_search_path = search_path.end_with?("/") ? search_path : "#{search_path}/"
375
+
376
+ # Calculate relative path from the search path
377
+ relative_path = if file_path.start_with?(normalized_search_path)
378
+ file_path[normalized_search_path.length..]
379
+ else
380
+ # Fallback to original logic if path doesn't start with search_path
381
+ file_path.sub("#{search_path}/", "")
382
+ end
383
+
384
+ # Remove extension for resource path
385
+ protocol_config = @config_loader.load_protocol_config(protocol)
386
+ extensions = protocol_config["extensions"] || []
387
+ inferred_extensions = protocol_config["inferred_extensions"] || extensions
388
+
389
+ # Combine both lists for extension stripping
390
+ all_extensions = extensions | inferred_extensions
391
+
392
+ resource_path = relative_path
393
+ all_extensions.each do |ext|
394
+ resource_path = resource_path.sub(ext, "") if resource_path.end_with?(ext)
395
+ end
396
+
397
+ {
398
+ path: file_path,
399
+ relative_path: resource_path,
400
+ source: source,
401
+ protocol: protocol
402
+ }
403
+ end
404
+
405
+ def scan_project_source
406
+ project_path = File.expand_path("./.ace-handbook")
407
+ return nil unless Dir.exist?(project_path)
408
+
409
+ Models::HandbookSource.new(
410
+ name: "project",
411
+ path: project_path,
412
+ alias_name: "@project",
413
+ type: :project,
414
+ priority: 10
415
+ )
416
+ end
417
+
418
+ def scan_user_source
419
+ user_path = File.expand_path("~/.ace-handbook")
420
+ return nil unless Dir.exist?(user_path)
421
+
422
+ Models::HandbookSource.new(
423
+ name: "user",
424
+ path: user_path,
425
+ alias_name: "@user",
426
+ type: :user,
427
+ priority: 20
428
+ )
429
+ end
430
+ end
431
+ end
432
+ end
433
+ end
434
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/uri_parser"
4
+ require_relative "../models/resource"
5
+ require_relative "../models/resource_uri"
6
+ require_relative "protocol_scanner"
7
+
8
+ module Ace
9
+ module Support
10
+ module Nav
11
+ module Molecules
12
+ # Resolves resource URIs to file paths
13
+ class ResourceResolver
14
+ def initialize(handbook_scanner: nil, uri_parser: nil, protocol_scanner: nil)
15
+ # Support legacy handbook_scanner parameter
16
+ @protocol_scanner = protocol_scanner || handbook_scanner || ProtocolScanner.new
17
+ @uri_parser = uri_parser || Atoms::UriParser.new
18
+ end
19
+
20
+ def resolve(uri_string)
21
+ # Parse the URI
22
+ uri = Models::ResourceUri.new(uri_string)
23
+ return nil unless uri.valid?
24
+
25
+ if uri.source_specific?
26
+ resolve_source_specific(uri)
27
+ else
28
+ resolve_cascade(uri)
29
+ end
30
+ end
31
+
32
+ def resolve_pattern(uri_string)
33
+ # Parse the URI
34
+ uri = Models::ResourceUri.new(uri_string)
35
+ return [] unless uri.valid?
36
+
37
+ pattern = uri.path || "*"
38
+
39
+ if uri.source_specific?
40
+ resolve_pattern_source_specific(uri, pattern)
41
+ else
42
+ resolve_pattern_cascade(uri, pattern)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def resolve_source_specific(uri)
49
+ # Get the specific source
50
+ source = @protocol_scanner.scan_source_by_alias(uri.source)
51
+ return nil unless source
52
+
53
+ # Find the resource in that source
54
+ resources = @protocol_scanner.find_resources_in_source(
55
+ source,
56
+ uri.protocol,
57
+ uri.path || "*"
58
+ )
59
+
60
+ return nil if resources.empty?
61
+
62
+ # Return the first match as a Resource
63
+ resource_info = resources.first
64
+ Models::Resource.new(
65
+ uri: uri.to_s,
66
+ path: resource_info[:path],
67
+ source: resource_info[:source],
68
+ protocol: uri.protocol,
69
+ resource_path: resource_info[:relative_path]
70
+ )
71
+ end
72
+
73
+ def resolve_cascade(uri)
74
+ # Use protocol scanner to find resources directly
75
+ resources = @protocol_scanner.find_resources(uri.protocol, uri.path || "*")
76
+
77
+ return nil if resources.empty?
78
+
79
+ # Return the first match (already sorted by priority)
80
+ resource_info = resources.first
81
+ Models::Resource.new(
82
+ uri: uri.to_s,
83
+ path: resource_info[:path],
84
+ source: resource_info[:source],
85
+ protocol: uri.protocol,
86
+ resource_path: resource_info[:relative_path]
87
+ )
88
+ end
89
+
90
+ def resolve_pattern_source_specific(uri, pattern)
91
+ # Get the specific source
92
+ source = @protocol_scanner.scan_source_by_alias(uri.source)
93
+ return [] unless source
94
+
95
+ # Find matching resources in that source
96
+ resources = @protocol_scanner.find_resources_in_source(
97
+ source,
98
+ uri.protocol,
99
+ pattern
100
+ )
101
+
102
+ resources.map do |resource_info|
103
+ Models::Resource.new(
104
+ uri: "#{uri.protocol}://#{uri.source}/#{resource_info[:relative_path]}",
105
+ path: resource_info[:path],
106
+ source: resource_info[:source],
107
+ protocol: uri.protocol,
108
+ resource_path: resource_info[:relative_path]
109
+ )
110
+ end
111
+ end
112
+
113
+ def resolve_pattern_cascade(uri, pattern)
114
+ # Use protocol scanner to find all matching resources
115
+ resources = @protocol_scanner.find_resources(uri.protocol, pattern)
116
+ all_resources = []
117
+
118
+ resources.each do |resource_info|
119
+ all_resources << Models::Resource.new(
120
+ uri: "#{uri.protocol}://#{resource_info[:relative_path]}",
121
+ path: resource_info[:path],
122
+ source: resource_info[:source],
123
+ protocol: uri.protocol,
124
+ resource_path: resource_info[:relative_path]
125
+ )
126
+ end
127
+
128
+ all_resources
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+ require "ace/support/fs"
6
+ require_relative "../models/protocol_source"
7
+
8
+ module Ace
9
+ module Support
10
+ module Nav
11
+ module Molecules
12
+ # Discovers and manages protocol source registrations
13
+ class SourceRegistry
14
+ attr_reader :start_path
15
+
16
+ def initialize(start_path: nil)
17
+ @start_path = start_path
18
+ @sources_cache = {}
19
+ end
20
+
21
+ # Get all sources for a protocol
22
+ def sources_for_protocol(protocol)
23
+ @sources_cache[protocol] ||= discover_sources(protocol)
24
+ end
25
+
26
+ # Clear the cache
27
+ def clear_cache
28
+ @sources_cache.clear
29
+ end
30
+
31
+ private
32
+
33
+ def discover_sources(protocol)
34
+ sources = []
35
+
36
+ # Discover from user directory
37
+ sources.concat(discover_user_sources(protocol))
38
+
39
+ # Discover from project hierarchy
40
+ sources.concat(discover_project_sources(protocol))
41
+
42
+ # Sort by priority (lower number = higher priority)
43
+ sources.sort_by(&:priority)
44
+ end
45
+
46
+ def discover_user_sources(protocol)
47
+ sources = []
48
+ user_sources_dir = File.expand_path("~/.ace/nav/protocols/#{protocol}-sources")
49
+
50
+ return sources unless Dir.exist?(user_sources_dir)
51
+
52
+ Dir.glob(File.join(user_sources_dir, "*.yml")).each do |source_file|
53
+ source = load_source_file(source_file, "user")
54
+ sources << source if source
55
+ end
56
+
57
+ sources
58
+ end
59
+
60
+ def discover_project_sources(protocol)
61
+ sources = []
62
+
63
+ # Use directory traverser to find all .ace directories up to project root
64
+ traverser = Ace::Support::Fs::Molecules::DirectoryTraverser.new(start_path: start_path || Dir.pwd)
65
+ config_dirs = traverser.find_config_directories
66
+
67
+ # Check each .ace directory for protocol sources
68
+ config_dirs.each do |config_dir|
69
+ sources_dir = File.join(config_dir, "nav/protocols", "#{protocol}-sources")
70
+ next unless Dir.exist?(sources_dir)
71
+
72
+ Dir.glob(File.join(sources_dir, "*.yml")).each do |source_file|
73
+ source = load_source_file(source_file, "project")
74
+ sources << source if source
75
+ end
76
+ end
77
+
78
+ sources
79
+ end
80
+
81
+ def load_source_file(file_path, origin)
82
+ data = YAML.load_file(file_path)
83
+ return nil unless data.is_a?(Hash)
84
+
85
+ # Expand environment variables in path (only for non-gem types)
86
+ path = (data["type"] == "gem") ? nil : expand_path(data["path"]) if data["path"]
87
+
88
+ # Log warning if path is provided for gem type
89
+ if data["type"] == "gem" && data["path"]
90
+ warn "Warning: 'path' field is ignored for gem type sources (#{data["name"]})" if ENV["VERBOSE"]
91
+ end
92
+
93
+ Models::ProtocolSource.new(
94
+ name: data["name"] || File.basename(file_path, ".yml"),
95
+ type: data["type"] || "directory",
96
+ path: path,
97
+ priority: data["priority"] || default_priority(origin),
98
+ description: data["description"],
99
+ origin: origin,
100
+ config_file: file_path,
101
+ config_dir: file_path, # Pass the config file path for relative resolution
102
+ config: data["config"] # Pass the config section
103
+ )
104
+ rescue => e
105
+ warn "Failed to load source file #{file_path}: #{e.message}"
106
+ nil
107
+ end
108
+
109
+ def expand_path(path)
110
+ # Expand environment variables
111
+ path = path.gsub(/\$(\w+)/) { ENV.fetch($1, "") }
112
+ path = path.gsub("$HOME", ENV["HOME"]) if ENV["HOME"]
113
+ path = path.gsub("$USER", ENV["USER"]) if ENV["USER"]
114
+
115
+ # Don't expand relative paths for gem sources
116
+ path
117
+ end
118
+
119
+ def default_priority(origin)
120
+ case origin
121
+ when "project"
122
+ 10 # Highest priority
123
+ when "user"
124
+ 50 # Medium priority
125
+ else
126
+ 100 # Lower priority for gems
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end