agent_skills_configurations 0.1.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,589 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module AgentSkillsConfigurations
6
+ # Loads agent configurations, resolves paths, and exposes query helpers.
7
+ #
8
+ # The Registry is the internal implementation class that handles:
9
+ #
10
+ # * Loading and parsing the YAML configuration file
11
+ # * Resolving environment variables to absolute paths
12
+ # * Handling fallback paths for missing directories
13
+ # * Detecting which agents have their paths present
14
+ # * Caching results for performance
15
+ #
16
+ # This class is typically used through the public API methods in the
17
+ # {AgentSkillsConfigurations} module, but can also be used directly for
18
+ # advanced use cases.
19
+ #
20
+ # == Path Resolution
21
+ #
22
+ # The Registry uses a sophisticated path resolution system that respects
23
+ # environment variables and provides fallback locations. The resolution
24
+ # process works as follows:
25
+ #
26
+ # 1. Check if the configured environment variable is set and non-empty
27
+ # 2. If set, use that value as the base path
28
+ # 3. If not set, use the configured fallback path (often relative to home)
29
+ # 4. Expand relative paths using the user's home directory
30
+ #
31
+ # Example resolution flow for XDG_CONFIG_HOME:
32
+ #
33
+ # # Configuration in YAML:
34
+ # base_paths:
35
+ # xdg_config:
36
+ # env_var: XDG_CONFIG_HOME
37
+ # fallback: ".config"
38
+ #
39
+ # # With environment variable set:
40
+ # ENV["XDG_CONFIG_HOME"] = "/custom/xdg"
41
+ # # => resolves to "/custom/xdg"
42
+ #
43
+ # # Without environment variable:
44
+ # ENV["XDG_CONFIG_HOME"] = nil
45
+ # # => resolves to "/Users/username/.config"
46
+ #
47
+ # # Empty environment variable treated as unset:
48
+ # ENV["XDG_CONFIG_HOME"] = ""
49
+ # # => resolves to "/Users/username/.config"
50
+ #
51
+ # == Global Skills Path Resolution
52
+ #
53
+ # Global skills paths are resolved relative to the agent's base path
54
+ # and support multiple fallbacks. The Registry checks each candidate path
55
+ # in order and returns the first one that exists:
56
+ #
57
+ # # Configuration in YAML:
58
+ # agents:
59
+ # - name: moltbot
60
+ # base_path: home
61
+ # global_skills_path: ".moltbot/skills"
62
+ # global_skills_path_fallbacks:
63
+ # - ".clawdbot/skills"
64
+ # - ".moltbot/skills"
65
+ #
66
+ # # Resolution order:
67
+ # # 1. Check ~/.moltbot/skills
68
+ # # 2. Check ~/.clawdbot/skills
69
+ # # 3. Check ~/.moltbot/skills (fallback)
70
+ # # 4. Return first existing path, or primary path if none exist
71
+ #
72
+ # == Agent Detection
73
+ #
74
+ # The Registry determines whether an agent's paths are present by checking the
75
+ # paths configured in the agent's +detect_paths+ array. Each path spec
76
+ # can be one of several types:
77
+ #
78
+ # * *String*: Check if the path exists relative to the user's home directory
79
+ # * *Hash with +cwd+*: Check if the path exists relative to the current working directory
80
+ # * *Hash with +base+ and +path+*: Check if the path exists relative to a configured base path
81
+ # * *Hash with +absolute+*: Check if the absolute path exists
82
+ #
83
+ # An agent is considered detected if <b>any</b> of its detect paths exists.
84
+ #
85
+ # Examples of detect paths:
86
+ #
87
+ # agents:
88
+ # - name: cursor
89
+ # detect_paths:
90
+ # - ".cursor" # Check ~/.cursor exists
91
+ #
92
+ # - name: antigravity
93
+ # detect_paths:
94
+ # - { cwd: ".agent" } # Check .agent exists in current dir
95
+ # - { base: home, path: ".gemini/antigravity" } # Check ~/.gemini/antigravity exists
96
+ #
97
+ # - name: codex
98
+ # detect_paths:
99
+ # - "" # Always detected (empty string matches)
100
+ # - { absolute: "/etc/codex" } # Check /etc/codex exists
101
+ #
102
+ # == Caching
103
+ #
104
+ # The Registry caches the results of {all} and {detected} to avoid
105
+ # repeatedly parsing the YAML file and checking file system paths.
106
+ # The cache can be cleared using {reset}.
107
+ #
108
+ # Use {reset} when:
109
+ #
110
+ # * Environment variables that affect path resolution have changed
111
+ # * Agent paths have been created or removed
112
+ # * The YAML configuration file has been modified
113
+ #
114
+ # @see AgentSkillsConfigurations Public API module that uses this class
115
+ # @see YAML_PATH Path to the configuration file
116
+ class Registry
117
+ # Absolute path to the configuration file.
118
+ #
119
+ # The configuration file contains agent definitions, base path configurations,
120
+ # and detection rules. This path is resolved relative to the gem's lib directory.
121
+ #
122
+ # @return [String] absolute path to agents.yml
123
+ YAML_PATH = File.expand_path("agents.yml", __dir__)
124
+
125
+ # Create a registry from the YAML configuration.
126
+ #
127
+ # Loads the agents.yml file and parses it into a data structure that
128
+ # can be queried for agent information. The YAML is loaded safely
129
+ # with permitted classes for security.
130
+ #
131
+ # @return [Registry] a new registry instance with loaded configuration
132
+ # @raise [Psych::SyntaxError] when the YAML is invalid or malformed
133
+ #
134
+ # @example Create a registry
135
+ # registry = AgentSkillsConfigurations::Registry.new
136
+ # registry.find("cursor").name # => "cursor"
137
+ def initialize
138
+ @data = YAML.safe_load_file(YAML_PATH, permitted_classes: [Hash], aliases: true)
139
+ end
140
+
141
+ # Find an agent by name.
142
+ #
143
+ # Looks up an agent configuration by its canonical name and returns an
144
+ # {Agent} value object with resolved paths. This method performs path
145
+ # resolution each time it's called, so it reflects the current
146
+ # environment variables and file system state.
147
+ #
148
+ # @param name [String] the canonical agent name from agents.yml
149
+ # @return [Agent] the resolved agent configuration with absolute paths
150
+ # @raise [Error] when the agent name is unknown
151
+ #
152
+ # @example Find a specific agent
153
+ # registry = AgentSkillsConfigurations::Registry.new
154
+ # agent = registry.find("cursor")
155
+ # agent.name # => "cursor"
156
+ # agent.global_skills_dir # => "/Users/username/.cursor/skills"
157
+ #
158
+ # @example Error for unknown agent
159
+ # registry.find("unknown-agent")
160
+ # # => raises AgentSkillsConfigurations::Error: Unknown agent: unknown-agent
161
+ def find(name)
162
+ entry = @data["agents"].find { |a| a["name"] == name }
163
+ raise Error, "Unknown agent: #{name}" unless entry
164
+
165
+ build_agent(entry)
166
+ end
167
+
168
+ # Return all configured agents.
169
+ #
170
+ # Returns a frozen array of all {Agent} objects defined in the configuration.
171
+ # The result is cached on first call for performance. Path resolution happens
172
+ # once during caching and the results are reused on subsequent calls.
173
+ #
174
+ # Use {reset} to clear the cache and force re-resolution when needed.
175
+ #
176
+ # @return [Array<Agent>] frozen array of all agents with resolved paths
177
+ # @raise [Psych::SyntaxError] when the YAML is invalid (only on first call)
178
+ #
179
+ # @example Get all agents
180
+ # registry = AgentSkillsConfigurations::Registry.new
181
+ # all = registry.all
182
+ # all.map(&:name)
183
+ # # => ["amp", "claude-code", "cursor", "codex", ...]
184
+ #
185
+ # @example Verify array is frozen and cached
186
+ # registry = AgentSkillsConfigurations::Registry.new
187
+ # first_call = registry.all
188
+ # second_call = registry.all
189
+ # first_call.equal?(second_call) # => true (same object)
190
+ # first_call.frozen? # => true
191
+ #
192
+ # @see #reset Clear the cache
193
+ # @see #detected Get only detected agents
194
+ def all
195
+ @all ||= @data["agents"].map { |entry| build_agent(entry) }.freeze
196
+ end
197
+
198
+ # Return agents detected on this machine.
199
+ #
200
+ # Filters the list of all agents to those that have their detect paths present.
201
+ # Detection uses the paths configured in each agent's
202
+ # <tt>detect_paths</tt> configuration. An agent is considered detected
203
+ # if <b>any</b> of its detect paths exists.
204
+ #
205
+ # Detection strategies:
206
+ #
207
+ # * String: Check if path exists relative to user's home directory
208
+ # * Hash with +cwd+: Check relative to current working directory
209
+ # * Hash with +base+ and +path+: Check relative to configured base path
210
+ # * Hash with +absolute+: Check absolute path directly
211
+ #
212
+ # The result is cached on first call. Use {reset} to clear the cache.
213
+ #
214
+ # @return [Array<Agent>] frozen array of detected agents with resolved paths
215
+ # @raise [Psych::SyntaxError] when the YAML is invalid (only on first call)
216
+ #
217
+ # @example Get detected agents
218
+ # registry = AgentSkillsConfigurations::Registry.new
219
+ # detected = registry.detected
220
+ # detected.map(&:name)
221
+ # # => ["cursor", "claude-code"]
222
+ #
223
+ # @example Check if specific agent is detected
224
+ # registry = AgentSkillsConfigurations::Registry.new
225
+ # detected_names = registry.detected.map(&:name)
226
+ # detected_names.include?("cursor") # => true (if detected)
227
+ # detected_names.include?("unknown") # => false
228
+ #
229
+ # @see #all Get all configured agents
230
+ # @see #reset Clear the cache
231
+ def detected
232
+ @detected ||= all.select { |agent| detected?(agent) }.freeze
233
+ end
234
+
235
+ # Clear cached agent lists.
236
+ #
237
+ # Clears the internal caches for {all} and {detected} results. This
238
+ # forces path resolution to be re-executed on the next call, which is
239
+ # useful when:
240
+ #
241
+ # * Environment variables affecting path resolution have changed
242
+ # * Agent paths have been created or removed
243
+ # * The YAML configuration file has been modified
244
+ #
245
+ # @return [void]
246
+ #
247
+ # @example Reset after changing environment variable
248
+ # registry = AgentSkillsConfigurations::Registry.new
249
+ # ENV["XDG_CONFIG_HOME"] = "/new/path"
250
+ # registry.reset
251
+ # agent = registry.find("amp")
252
+ # agent.global_skills_dir # => "/new/path/agents/skills"
253
+ #
254
+ # @example Reset for fresh detection
255
+ # registry = AgentSkillsConfigurations::Registry.new
256
+ # # Create agent paths...
257
+ # registry.reset
258
+ # registry.detected # => includes newly detected agent
259
+ #
260
+ # @see #all Get all agents
261
+ # @see #detected Get detected agents
262
+ def reset
263
+ @all = nil
264
+ @detected = nil
265
+ end
266
+
267
+ private
268
+
269
+ # Build an agent value object from a YAML entry.
270
+ #
271
+ # Creates an {Agent} object by resolving the base path and global skills
272
+ # path from the YAML configuration. The base path is resolved first,
273
+ # then used as the context for resolving the global skills path (including
274
+ # any fallbacks).
275
+ #
276
+ # @param entry [Hash] the agent entry from the YAML configuration
277
+ # @return [Agent] a new Agent instance with resolved absolute paths
278
+ #
279
+ # @example Build an agent from YAML entry
280
+ # entry = {
281
+ # "name" => "cursor",
282
+ # "display_name" => "Cursor",
283
+ # "skills_dir" => ".cursor/skills",
284
+ # "base_path" => "home",
285
+ # "global_skills_path" => ".cursor/skills"
286
+ # }
287
+ # build_agent(entry)
288
+ # # => #<Agent name="cursor" display_name="Cursor" ...>
289
+ def build_agent(entry)
290
+ base = resolve_base_path(entry["base_path"])
291
+ global_path = resolve_global_skills_path(entry, base)
292
+
293
+ Agent.new(
294
+ name: entry["name"],
295
+ display_name: entry["display_name"],
296
+ skills_dir: entry["skills_dir"],
297
+ global_skills_dir: global_path
298
+ )
299
+ end
300
+
301
+ # Resolve a base path key into an absolute path.
302
+ #
303
+ # Looks up a base path configuration by key and resolves it to an absolute
304
+ # path using the following priority:
305
+ #
306
+ # 1. If env_var is "~" or "", return the home directory directly
307
+ # 2. If env_var is set and non-empty, use that value as the path
308
+ # 3. Otherwise, use the configured fallback path (relative to home)
309
+ #
310
+ # This method ensures that empty environment variables are treated the same
311
+ # as unset variables, preventing unexpected behavior.
312
+ #
313
+ # @param key [String] the base path key from the YAML configuration
314
+ # @return [String] an absolute path
315
+ #
316
+ # @example Resolve XDG_CONFIG_HOME with environment variable
317
+ # # YAML: base_paths.xdg_config.env_var = "XDG_CONFIG_HOME"
318
+ # # YAML: base_paths.xdg_config.fallback = ".config"
319
+ # ENV["XDG_CONFIG_HOME"] = "/custom/xdg"
320
+ # resolve_base_path("xdg_config") # => "/custom/xdg"
321
+ #
322
+ # @example Resolve XDG_CONFIG_HOME without environment variable
323
+ # ENV["XDG_CONFIG_HOME"] = nil
324
+ # resolve_base_path("xdg_config") # => "/Users/username/.config"
325
+ #
326
+ # @example Resolve with empty environment variable
327
+ # ENV["XDG_CONFIG_HOME"] = ""
328
+ # resolve_base_path("xdg_config") # => "/Users/username/.config"
329
+ def resolve_base_path(key)
330
+ config = @data["base_paths"][key]
331
+ env_var = config["env_var"]
332
+ home = Dir.home || "/tmp"
333
+
334
+ return home if ["~", ""].include?(env_var)
335
+ return ENV.fetch(env_var, nil) if env_var_set?(env_var)
336
+
337
+ resolve_fallback(config["fallback"], home)
338
+ end
339
+
340
+ # True when an environment variable is set and non-empty.
341
+ #
342
+ # Checks if an environment variable exists and contains a non-empty value.
343
+ # This distinction is important because an empty string should be treated
344
+ # the same as an unset variable for path resolution purposes.
345
+ #
346
+ # @param env_var [String, nil] the environment variable name
347
+ # @return [Boolean] true if the variable is set and non-empty
348
+ #
349
+ # @example Set environment variable
350
+ # ENV["VAR"] = "value"
351
+ # env_var_set?("VAR") # => true
352
+ #
353
+ # @example Unset environment variable
354
+ # ENV.delete("VAR")
355
+ # env_var_set?("VAR") # => false
356
+ #
357
+ # @example Empty environment variable
358
+ # ENV["VAR"] = ""
359
+ # env_var_set?("VAR") # => false
360
+ #
361
+ # @example Nil environment variable name
362
+ # env_var_set?(nil) # => false
363
+ def env_var_set?(env_var)
364
+ env_var && !ENV[env_var].nil? && !ENV[env_var].empty?
365
+ end
366
+
367
+ # Resolve a fallback path relative to the user's home directory.
368
+ #
369
+ # Joins the fallback path with the home directory, unless the fallback is
370
+ # "~" or an empty string, in which case the home directory is returned
371
+ # directly.
372
+ #
373
+ # @param fallback [String] the fallback path from YAML configuration
374
+ # @param home [String] the user's home directory
375
+ # @return [String] an absolute path
376
+ #
377
+ # @example Normal fallback
378
+ # resolve_fallback(".config", "/Users/username")
379
+ # # => "/Users/username/.config"
380
+ #
381
+ # @example Tilde fallback (use home directly)
382
+ # resolve_fallback("~", "/Users/username")
383
+ # # => "/Users/username"
384
+ #
385
+ # @example Empty fallback (use home directly)
386
+ # resolve_fallback("", "/Users/username")
387
+ # # => "/Users/username"
388
+ def resolve_fallback(fallback, home)
389
+ return home if ["~", ""].include?(fallback)
390
+
391
+ File.join(home, fallback)
392
+ end
393
+
394
+ # Resolve the global skills path, using fallbacks when needed.
395
+ #
396
+ # Resolves the primary global skills path relative to the base path, checking
397
+ # if it exists. If it doesn't exist, tries each fallback path in order.
398
+ # Returns the first existing path, or the primary path if none exist.
399
+ #
400
+ # This ensures that agents can gracefully handle missing directories by
401
+ # trying alternative locations before falling back to the primary path.
402
+ #
403
+ # @param entry [Hash] the agent entry from YAML configuration
404
+ # @param base_path [String] the resolved base path for this agent
405
+ # @return [String] an absolute path to the global skills directory
406
+ #
407
+ # @example Primary path exists
408
+ # # ~/.cursor/skills exists
409
+ # resolve_global_skills_path({ "global_skills_path" => ".cursor/skills" }, "/Users/username")
410
+ # # => "/Users/username/.cursor/skills"
411
+ #
412
+ # @example Use fallback when primary doesn't exist
413
+ # # ~/.moltbot/skills doesn't exist, ~/.clawdbot/skills exists
414
+ # entry = {
415
+ # "global_skills_path" => ".moltbot/skills",
416
+ # "global_skills_path_fallbacks" => [".clawdbot/skills", ".moltbot/skills"]
417
+ # }
418
+ # resolve_global_skills_path(entry, "/Users/username")
419
+ # # => "/Users/username/.clawdbot/skills"
420
+ #
421
+ # @example Return primary path if none exist
422
+ # # No paths exist
423
+ # resolve_global_skills_path({ "global_skills_path" => ".unknown/skills" }, "/Users/username")
424
+ # # => "/Users/username/.unknown/skills"
425
+ def resolve_global_skills_path(entry, base_path)
426
+ primary = entry["global_skills_path"]
427
+ fallbacks = entry["global_skills_path_fallbacks"] || []
428
+
429
+ candidates = [primary] + fallbacks
430
+ candidates.each do |path|
431
+ resolved = File.expand_path(path, base_path)
432
+ return resolved if Dir.exist?(resolved)
433
+ end
434
+
435
+ File.expand_path(primary, base_path)
436
+ end
437
+
438
+ # Detect whether an agent's paths are present based on configured paths.
439
+ #
440
+ # Checks each of the agent's configured detection paths. The agent is
441
+ # considered detected if <b>any</b> of the paths exists. Detection paths
442
+ # can be strings or hashes with different resolution strategies.
443
+ #
444
+ # @param agent [Agent] the agent to check for detection
445
+ # @return [Boolean] true if any detection path exists
446
+ #
447
+ # @example Agent with detect paths
448
+ # # YAML: detect_paths: [".cursor"]
449
+ # agent = registry.find("cursor")
450
+ # detected?(agent) # => true if ~/.cursor exists
451
+ #
452
+ # @example Agent with no detect paths (not detected)
453
+ # # YAML: detect_paths: []
454
+ # agent = registry.find("some-agent")
455
+ # detected?(agent) # => false
456
+ #
457
+ # @see #detect_path Individual path detection logic
458
+ def detected?(agent)
459
+ entry = @data["agents"].find { |a| a["name"] == agent.name }
460
+ return false unless entry
461
+
462
+ detect_paths = entry["detect_paths"] || []
463
+
464
+ detect_paths.any? do |detect_spec|
465
+ path?(detect_spec)
466
+ end
467
+ end
468
+
469
+ # Detect a path using the configured spec.
470
+ #
471
+ # Dispatches to the appropriate detection method based on the type
472
+ # of the detection specification:
473
+ #
474
+ # * String: Check relative to home directory
475
+ # * Hash: Check based on hash keys (cwd, base, absolute)
476
+ # * Other: Return false
477
+ #
478
+ # @param spec [String, Hash] the detection specification from YAML
479
+ # @return [Boolean] true if the path exists
480
+ #
481
+ # @example String detection
482
+ # path?(".cursor") # => true if ~/.cursor exists
483
+ #
484
+ # @example Hash detection with cwd
485
+ # path?({ cwd: ".agent" }) # => true if ./.agent exists
486
+ #
487
+ # @example Hash detection with absolute
488
+ # path?({ absolute: "/etc/codex" }) # => true if /etc/codex exists
489
+ #
490
+ # @see #string_path? String detection logic
491
+ # @see #hash_path? Hash detection logic
492
+ def path?(spec)
493
+ case spec
494
+ when String
495
+ string_path?(spec)
496
+ when Hash
497
+ hash_path?(spec)
498
+ else
499
+ false
500
+ end
501
+ end
502
+
503
+ # Detect a string spec relative to the user's home directory.
504
+ #
505
+ # For non-empty strings, checks if the path exists relative to the user's
506
+ # home directory. An empty string is treated as always true, which can
507
+ # be used to mark an agent as always detected.
508
+ #
509
+ # @param spec [String] a path relative to the home directory
510
+ # @return [Boolean] true if the path exists or spec is empty
511
+ #
512
+ # @example Normal string detection
513
+ # string_path?(".cursor") # => true if ~/.cursor exists
514
+ #
515
+ # @example Empty string (always detected)
516
+ # string_path?("") # => true
517
+ #
518
+ # @example Non-existent path
519
+ # string_path?(".does-not-exist") # => false
520
+ def string_path?(spec)
521
+ spec.empty? || File.exist?(File.join(Dir.home, spec))
522
+ end
523
+
524
+ # Detect a hash spec with absolute/cwd/base rules.
525
+ #
526
+ # Handles hash-based detection specifications with different strategies:
527
+ #
528
+ # * +absolute+: Check the absolute path directly
529
+ # * +cwd+: Check relative to current working directory
530
+ # * +base+ and +path+: Check relative to a configured base path
531
+ #
532
+ # Only one strategy is applied per hash, in the order above.
533
+ #
534
+ # @param spec [Hash] a hash with detection strategy and path
535
+ # @return [Boolean] true if the path exists
536
+ #
537
+ # @example Absolute path detection
538
+ # hash_path?({ absolute: "/etc/codex" })
539
+ # # => true if /etc/codex exists
540
+ #
541
+ # @example Current working directory detection
542
+ # hash_path?({ cwd: ".agent" })
543
+ # # => true if ./.agent exists (relative to Dir.pwd)
544
+ #
545
+ # @example Base path detection
546
+ # # Assuming home base path resolves to /Users/username
547
+ # hash_path?({ base: "home", path: ".codex" })
548
+ # # => true if /Users/username/.codex exists
549
+ #
550
+ # @see #base_path? Base path resolution logic
551
+ def hash_path?(spec)
552
+ return File.exist?(spec[:absolute]) if spec.key?(:absolute)
553
+ return File.exist?(File.join(Dir.pwd, spec[:cwd])) if spec.key?(:cwd)
554
+ return base_path?(spec) if spec.key?(:base) && spec.key?(:path)
555
+
556
+ false
557
+ end
558
+
559
+ # Detect a path relative to a configured base path.
560
+ #
561
+ # Resolves a base path configuration and checks if the specified path
562
+ # exists relative to it. If the path is empty, checks if the base path
563
+ # directory itself exists.
564
+ #
565
+ # @param spec [Hash] a hash with +base+ key and optional +path+ key
566
+ # @return [Boolean] true if the path exists
567
+ #
568
+ # @example Non-empty path
569
+ # # home base path resolves to /Users/username
570
+ # base_path?({ base: "home", path: ".codex" })
571
+ # # => true if /Users/username/.codex exists
572
+ #
573
+ # @example Empty path (check base directory)
574
+ # base_path?({ base: "home", path: "" })
575
+ # # => true if /Users/username exists (home directory)
576
+ #
577
+ # @see #resolve_base_path Base path resolution logic
578
+ def base_path?(spec)
579
+ base = resolve_base_path(spec[:base])
580
+ path = spec[:path]
581
+
582
+ if path.empty?
583
+ Dir.exist?(base)
584
+ else
585
+ File.exist?(File.join(base, path))
586
+ end
587
+ end
588
+ end
589
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentSkillsConfigurations
4
+ # Gem version.
5
+ #
6
+ # @return [String]
7
+ VERSION = "0.1.0"
8
+ end