agents_homedir 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 70b9327c32fb87519bf706ebff96bcf9ad9e9958a1f07343d243c7b9b0fd2f48
4
+ data.tar.gz: d9aa3e2a90f74614da2ac65d5509e450c55d18c8c7f98a7e176098aa49205a50
5
+ SHA512:
6
+ metadata.gz: fc955ace89303a11c2c837f03055a850a2b5f079a6aafac1a22d3f3f4cc6e6ee50615d62c311417d083b73909561eca6defb431e1cfb94399f60db3dcc2bef1f
7
+ data.tar.gz: 80208dde85e0c12bbb7b9283dcea419ba3af54f6d02a33165209bfee1f1e722f0207dbbff52f18ab62fa71d25d4dc0bfaab0f3e0a216bae1f2a509dbdd92368f
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Lucian Ghinda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # AgentsHomedir
2
+
3
+ Find the home folder an AI coding agent uses, without reading contents.
4
+
5
+ ## Installation
6
+
7
+ Use Ruby 3.2 or newer. The gem ships with one runtime dependency: Zeitwerk 2.8.
8
+
9
+ Add this line to your application's **Gemfile**:
10
+
11
+ ```ruby
12
+ gem "agents_homedir"
13
+ ```
14
+
15
+ Then run:
16
+
17
+ ```sh
18
+ bundle install
19
+ ```
20
+
21
+ Or install it directly:
22
+
23
+ ```sh
24
+ gem install agents_homedir
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ Get a home path:
30
+
31
+ ```ruby
32
+ require "agents_homedir"
33
+
34
+ AgentsHomedir.home(:claude_code)
35
+ ```
36
+
37
+ Check installation:
38
+
39
+ ```ruby
40
+ require "agents_homedir"
41
+
42
+ AgentsHomedir.installed?(:codex)
43
+ ```
44
+
45
+ Expect a `Pathname` and a boolean.
46
+
47
+ The module facade is snapshotted on first access.
48
+ That snapshots `ENV` and `HOME` for the rest of the process.
49
+ If you need a different snapshot, instantiate `AgentsHomedir::Resolver` directly.
50
+
51
+ ## Usage
52
+
53
+ Fetch an agent:
54
+
55
+ ```ruby
56
+ require "agents_homedir"
57
+
58
+ agent = AgentsHomedir[:codex]
59
+
60
+ agent.name
61
+ agent.label
62
+ agent.home
63
+ ```
64
+
65
+ Discover the catalog:
66
+
67
+ ```ruby
68
+ require "agents_homedir"
69
+
70
+ AgentsHomedir.names
71
+ AgentsHomedir.agents
72
+ AgentsHomedir.installed
73
+ ```
74
+
75
+ Expect ordered, frozen arrays.
76
+
77
+ Inspect agent facts:
78
+
79
+ ```ruby
80
+ require "agents_homedir"
81
+
82
+ agent = AgentsHomedir[:claude_code]
83
+
84
+ agent.env_override
85
+ agent.verified_on
86
+ agent.candidates
87
+ ```
88
+
89
+ Resolution order:
90
+
91
+ - Use a nonblank native override.
92
+ - Use the first existing directory candidate.
93
+ - Use the first candidate otherwise.
94
+
95
+ Agent facts:
96
+
97
+ - `name` is a `Symbol`.
98
+ - `label` is a `String`.
99
+ - `home` returns a `Pathname`.
100
+ - `installed?` returns a boolean.
101
+ - `candidates` returns an ordered, frozen array of `Pathname` objects.
102
+ - `env_override` is a `String` or `nil`.
103
+ - `verified_on` is a `Date` or `nil`.
104
+
105
+ Errors:
106
+
107
+ - Catch `AgentsHomedir::UnknownAgent` to list valid names.
108
+ - Catch `AgentsHomedir::HomeNotResolvable` for missing, blank, or nonabsolute `HOME`.
109
+
110
+ ## Testing
111
+
112
+ Pass `env`, `home`, and `os` explicitly.
113
+ Instantiate a fresh resolver when you need a different environment snapshot.
114
+ Do not stub global `ENV`.
115
+
116
+ ```ruby
117
+ require "agents_homedir"
118
+
119
+ resolver = AgentsHomedir::Resolver.new(
120
+ env: { "CODEX_HOME" => "custom/codex" },
121
+ home: "/Users/me",
122
+ os: :linux
123
+ )
124
+
125
+ resolver.home(:codex)
126
+ # => #<Pathname:/Users/me/custom/codex>
127
+ ```
128
+
129
+ ## Contributing
130
+
131
+ Activate Ruby 3.2 or newer.
132
+
133
+ Run the tests before sending a change.
134
+
135
+ ```sh
136
+ bundle install
137
+ bundle exec rake test
138
+ ```
139
+
140
+ ## License
141
+
142
+ MIT
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module AgentsHomedir
6
+ class Agent < Data.define(:name, :label, :env_override, :verified_on)
7
+ # Equality and hash intentionally cover only public facts.
8
+ # Resolver-specific caches should key by resolver and agent name.
9
+ def initialize(name:, label:, env_override:, verified_on:, resolver:)
10
+ validate_name!(name)
11
+ validate_label!(label)
12
+ validate_env_override!(env_override)
13
+ validate_verified_on!(verified_on)
14
+
15
+ @resolver = resolver
16
+
17
+ super(
18
+ name: name,
19
+ label: snapshot_string(label),
20
+ env_override: snapshot_string(env_override),
21
+ verified_on:
22
+ )
23
+ end
24
+
25
+ def home = @resolver.home(name)
26
+
27
+ def installed? = @resolver.installed?(name)
28
+
29
+ def candidates = @resolver.candidates(name)
30
+
31
+ def with(**changes)
32
+ return self if changes.empty?
33
+
34
+ unknown_members = changes.keys - self.class.members
35
+ fail ArgumentError, "Unknown member(s) for #{self.class}: #{unknown_members.map(&:inspect).join(', ')}" if unknown_members.any?
36
+ reject_resolver_bound_changes!(changes)
37
+
38
+ updated = to_h.merge(changes)
39
+ return self if updated == to_h
40
+
41
+ self.class.new(**updated, resolver: @resolver)
42
+ end
43
+
44
+ private
45
+
46
+ def reject_resolver_bound_changes!(changes)
47
+ [:name, :env_override].each do |field|
48
+ next unless changes.key?(field)
49
+ next if changes[field] == public_send(field)
50
+
51
+ fail ArgumentError, "#{field} is resolver-bound and cannot be changed via #with"
52
+ end
53
+ end
54
+
55
+ def validate_name!(value)
56
+ fail ArgumentError, "name must be a Symbol" unless value.is_a?(Symbol)
57
+ end
58
+
59
+ def validate_label!(value)
60
+ fail ArgumentError, "label must be a String" unless value.is_a?(String)
61
+ end
62
+
63
+ def validate_env_override!(value)
64
+ return if value.nil? || value.is_a?(String)
65
+
66
+ fail ArgumentError, "env_override must be a String or nil"
67
+ end
68
+
69
+ def validate_verified_on!(value)
70
+ return if value.nil? || value.is_a?(Date)
71
+
72
+ fail ArgumentError, "verified_on must be a Date or nil"
73
+ end
74
+
75
+ def snapshot_string(value)
76
+ return if value.nil?
77
+
78
+ value.dup.freeze
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsHomedir
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsHomedir
4
+ class HomeNotResolvable < Error; end
5
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsHomedir
4
+ module Registry
5
+ class << self
6
+ def entries
7
+ @entries ||= deep_freeze(
8
+ {
9
+ claude_code: agent("Claude Code", env: "CLAUDE_CONFIG_DIR", paths: "~/.claude", verified_on: "2026-08-05"),
10
+ codex: agent("Codex CLI", env: "CODEX_HOME", paths: "~/.codex", verified_on: "2026-07-21"),
11
+ gemini: agent("Gemini CLI", paths: "~/.gemini"),
12
+ antigravity_cli: agent("Antigravity CLI", paths: "~/.gemini/antigravity-cli"),
13
+ antigravity_ide: agent("Antigravity IDE", paths: "~/.gemini/antigravity-ide"),
14
+ antigravity_app: agent("Antigravity App", paths: "~/.gemini/antigravity"),
15
+ qwen: agent("Qwen Code", paths: "~/.qwen"),
16
+ pi: agent("Pi", env: "PI_CODING_AGENT_DIR", paths: "~/.pi/agent", verified_on: "2026-07-21"),
17
+ amp: agent("Amp", paths: [xdg_data("amp")], verified_on: "2026-07-21"),
18
+ opencode: agent(
19
+ "OpenCode",
20
+ env: "OPENCODE_DATA_DIR",
21
+ paths: {
22
+ macos: [xdg_data("opencode"), "~/Library/Application Support/opencode"],
23
+ linux: [xdg_data("opencode")],
24
+ windows: [
25
+ windows_env("XDG_DATA_HOME", "opencode", optional: true),
26
+ windows_env("APPDATA", "opencode"),
27
+ windows_env("LOCALAPPDATA", "opencode"),
28
+ "~/.local/share/opencode"
29
+ ]
30
+ },
31
+ verified_on: "2026-07-21"
32
+ ),
33
+ cursor: agent("Cursor", paths: "~/.cursor", verified_on: "2026-07-21"),
34
+ cursor_ide: agent(
35
+ "Cursor IDE",
36
+ paths: {
37
+ macos: "~/Library/Application Support/Cursor",
38
+ linux: xdg_config("Cursor"),
39
+ windows: windows_env("APPDATA", "Cursor")
40
+ }
41
+ ),
42
+ github_copilot_cli: agent("GitHub Copilot CLI", paths: "~/.copilot"),
43
+ vscode_copilot_chat: agent(
44
+ "VS Code Copilot Chat",
45
+ paths: {
46
+ macos: "~/Library/Application Support/Code/User",
47
+ linux: xdg_config("Code/User"),
48
+ windows: windows_env("APPDATA", "Code/User")
49
+ }
50
+ ),
51
+ cline: agent("Cline", paths: "~/.cline"),
52
+ cline_vscode: agent(
53
+ "Cline for VS Code",
54
+ paths: {
55
+ macos: "~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev",
56
+ linux: xdg_config("Code/User/globalStorage/saoudrizwan.claude-dev"),
57
+ windows: windows_env("APPDATA", "Code/User/globalStorage/saoudrizwan.claude-dev")
58
+ }
59
+ ),
60
+ grok_build: agent("Grok Build", paths: "~/.grok"),
61
+ vibe: agent("Vibe", paths: "~/.vibe"),
62
+ muse: agent("Muse Code", paths: [xdg_data("muse")]),
63
+ prime_agent: agent("Prime Agent", paths: "~/.prime/agent"),
64
+ deepseek_harness: agent("DeepSeek Harness", env: "DSH_HOME", paths: "~/.dsh"),
65
+ hermes: agent("Hermes Agent", env: "HERMES_HOME", paths: "~/.hermes"),
66
+ factory_droid: agent("Factory Droid", paths: "~/.factory"),
67
+ devin_cli: agent("Devin CLI", paths: "~/.config/devin"),
68
+ devin_desktop: agent("Devin Desktop", paths: "~/.codeium/windsurf")
69
+ }
70
+ )
71
+ end
72
+
73
+ private
74
+
75
+ def agent(label, env: nil, paths:, verified_on: nil)
76
+ {
77
+ label:,
78
+ env:,
79
+ paths:,
80
+ verified_on:
81
+ }
82
+ end
83
+
84
+ def xdg_config(path)
85
+ {xdg: :config, path:}
86
+ end
87
+
88
+ def xdg_data(path)
89
+ {xdg: :data, path:}
90
+ end
91
+
92
+ def windows_env(env_key, path, optional: false)
93
+ spec = {windows_env: env_key, path:}
94
+ optional ? spec.merge(optional: true) : spec
95
+ end
96
+
97
+ def deep_freeze(value)
98
+ case value
99
+ when Hash
100
+ value.each_with_object({}) do |(key, nested), copy|
101
+ copy[deep_freeze(key)] = deep_freeze(nested)
102
+ end.freeze
103
+ when Array
104
+ value.map { deep_freeze(_1) }.freeze
105
+ when String
106
+ value.dup.freeze
107
+ else
108
+ value
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ private_constant :Registry
115
+ end
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "agents_homedir"
4
+ require "date"
5
+ require "monitor"
6
+ require "pathname"
7
+ require "rbconfig"
8
+
9
+ module AgentsHomedir
10
+ class Resolver
11
+ VALID_OSES = %i[macos linux windows].freeze
12
+ WINDOWS_DRIVE_PATH = /\A[A-Za-z]:[\/\\]/.freeze
13
+ WINDOWS_UNC_PATH = /\A[\/\\]{2}[^\/\\]+[\/\\]+[^\/\\]+/.freeze
14
+
15
+ class << self
16
+ def default_os
17
+ @default_os ||= detect_os(RbConfig::CONFIG["host_os"])
18
+ end
19
+
20
+ private
21
+
22
+ def detect_os(host_os)
23
+ case host_os
24
+ when /darwin/i
25
+ :macos
26
+ when /linux/i
27
+ :linux
28
+ when /mswin|mingw/i
29
+ :windows
30
+ else
31
+ fail ArgumentError, "Unsupported host OS: #{host_os.inspect}"
32
+ end
33
+ end
34
+ end
35
+
36
+ def initialize(env: ENV.to_h, home: ENV["HOME"], os: self.class.default_os, entries: Registry.entries)
37
+ fail ArgumentError, "Invalid OS #{os.inspect}; expected one of #{VALID_OSES.inspect}" unless VALID_OSES.include?(os)
38
+
39
+ @env = snapshot_value(env)
40
+ @home = snapshot_value(home)
41
+ @os = os
42
+ @entries = snapshot_value(entries)
43
+ @memoization_monitor = Monitor.new
44
+ end
45
+
46
+ def home(name)
47
+ ensure_home_root!
48
+
49
+ override = env_override_for(name)
50
+ return override if override
51
+
52
+ available_candidates = candidates(name)
53
+ find_directory_candidate(available_candidates) || available_candidates.first
54
+ end
55
+
56
+ def installed?(name)
57
+ ensure_home_root!
58
+
59
+ override = env_override_for(name)
60
+ return true if directory_path?(override)
61
+
62
+ candidates(name).any? { directory_path?(_1) }
63
+ end
64
+
65
+ def candidates(name)
66
+ home_root = ensure_home_root!
67
+ specs = path_specs_for(name)
68
+ expanded = expand_specs(specs, home_root)
69
+ fail ArgumentError, no_paths_message(name, "current os paths are empty") if expanded.empty?
70
+
71
+ expanded.freeze
72
+ end
73
+
74
+ def [](name)
75
+ key = name.to_sym
76
+ entry = entry_for(key)
77
+
78
+ Agent.new(
79
+ name: key,
80
+ label: entry.fetch(:label),
81
+ env_override: entry[:env],
82
+ verified_on: parse_verified_on(key, entry[:verified_on]),
83
+ resolver: self
84
+ )
85
+ end
86
+
87
+ def names
88
+ return @names if @names
89
+
90
+ @memoization_monitor.synchronize do
91
+ @names ||= @entries.keys.freeze
92
+ end
93
+ end
94
+
95
+ def agents
96
+ return @agents if @agents
97
+
98
+ @memoization_monitor.synchronize do
99
+ @agents ||= names.map { self[_1] }.freeze
100
+ end
101
+ end
102
+
103
+ def installed
104
+ agents.select(&:installed?).freeze
105
+ end
106
+
107
+ private
108
+
109
+ def entry_for(name)
110
+ key = name.to_sym
111
+ entry = @entries[key]
112
+ return entry if entry
113
+
114
+ valid = @entries.keys.map(&:inspect).sort.join(", ")
115
+ fail UnknownAgent, "Unknown agent #{name.inspect}. Valid agents: #{valid}"
116
+ end
117
+
118
+ def path_specs_for(name)
119
+ specs = entry_for(name).fetch(:paths)
120
+ fail ArgumentError, no_paths_message(name, "paths are nil") if specs.nil?
121
+ fail ArgumentError, no_paths_message(name, "paths are empty") if specs.respond_to?(:empty?) && specs.empty?
122
+
123
+ specs
124
+ end
125
+
126
+ def env_override_for(name)
127
+ entry = entry_for(name)
128
+ env_key = entry[:env]
129
+ return unless env_key
130
+
131
+ raw = @env[env_key]
132
+ return if blank?(raw)
133
+
134
+ Pathname(resolve_path(raw, ensure_home_root!, allow_relative: true))
135
+ end
136
+
137
+ def ensure_home_root!
138
+ raw_home = @home.to_s
139
+ fail HomeNotResolvable, "HOME is not resolvable" if blank?(raw_home)
140
+
141
+ normalized = normalize_separators(raw_home)
142
+ fail HomeNotResolvable, "HOME is not resolvable" unless absolute_path?(normalized)
143
+
144
+ normalized
145
+ end
146
+
147
+ def expand_specs(specs, home_root)
148
+ return specs.flat_map { expand_spec(_1, home_root) } if specs.is_a?(Array)
149
+
150
+ expand_spec(specs, home_root)
151
+ end
152
+
153
+ def expand_spec(spec, home_root)
154
+ case spec
155
+ when Array
156
+ expand_specs(spec, home_root)
157
+ when String
158
+ [Pathname(resolve_registry_path(spec, home_root))]
159
+ when Hash
160
+ if os_selector?(spec)
161
+ selected = spec[@os]
162
+ selected ? expand_spec(selected, home_root) : []
163
+ elsif spec.key?(:xdg)
164
+ [Pathname(resolve_xdg_path(spec, home_root))]
165
+ elsif spec.key?(:windows_env)
166
+ resolved = resolve_windows_env_path(spec)
167
+ resolved ? [Pathname(resolved)] : []
168
+ else
169
+ fail ArgumentError, "Unsupported path spec #{spec.inspect}"
170
+ end
171
+ else
172
+ fail ArgumentError, "Unsupported path spec #{spec.inspect}"
173
+ end
174
+ end
175
+
176
+ def os_selector?(spec)
177
+ !(spec.keys & VALID_OSES).empty?
178
+ end
179
+
180
+ def resolve_registry_path(spec, home_root)
181
+ if spec == "~" || spec.start_with?("~/", "~\\")
182
+ expand_tilde(spec, home_root)
183
+ else
184
+ normalized = normalize_separators(spec)
185
+ ensure_absolute!(normalized)
186
+ end
187
+ end
188
+
189
+ def resolve_xdg_path(spec, home_root)
190
+ base =
191
+ case spec.fetch(:xdg)
192
+ when :config
193
+ resolve_xdg_base("XDG_CONFIG_HOME", join_path(home_root, ".config"))
194
+ when :data
195
+ resolve_xdg_base("XDG_DATA_HOME", join_path(home_root, ".local/share"))
196
+ else
197
+ fail ArgumentError, "Unsupported XDG base #{spec[:xdg].inspect}"
198
+ end
199
+
200
+ join_child_path(base, spec.fetch(:path), "XDG")
201
+ end
202
+
203
+ def resolve_xdg_base(env_key, fallback)
204
+ value = @env[env_key]
205
+ return fallback if blank?(value)
206
+
207
+ normalized = normalize_separators(value.to_s)
208
+ return fallback unless absolute_path?(normalized)
209
+
210
+ normalized
211
+ end
212
+
213
+ def resolve_windows_env_path(spec)
214
+ env_key = spec.fetch(:windows_env)
215
+ base_value = @env[env_key]
216
+
217
+ if blank?(base_value)
218
+ fallback_suffix = windows_home_fallback_suffix(env_key)
219
+ base_value = join_child_path(ensure_home_root!, fallback_suffix, env_key) if fallback_suffix
220
+ end
221
+
222
+ return if blank?(base_value) && spec.fetch(:optional, false)
223
+ fail ArgumentError, "Missing windows_env #{env_key} for #{spec.inspect}" if blank?(base_value)
224
+
225
+ if optional_windows_xdg_env?(env_key, spec)
226
+ normalized = normalize_separators(base_value.to_s)
227
+ return unless absolute_path?(normalized)
228
+
229
+ base_value = normalized
230
+ end
231
+
232
+ base = resolve_path(base_value, ensure_home_root!, allow_relative: false)
233
+ join_child_path(base, spec.fetch(:path), env_key)
234
+ end
235
+
236
+ def resolve_path(raw_path, home_root, allow_relative:)
237
+ normalized = normalize_separators(raw_path.to_s)
238
+ return expand_tilde(normalized, home_root) if normalized == "~" || normalized.start_with?("~/", "~\\")
239
+ return normalized if absolute_path?(normalized)
240
+ fail ArgumentError, "Resolved path must be an ordinary relative path: #{raw_path.inspect}" if invalid_relative_join_path?(normalized)
241
+
242
+ if allow_relative
243
+ join_relative(home_root, normalized)
244
+ else
245
+ fail ArgumentError, "Resolved path must be absolute: #{raw_path.inspect}"
246
+ end
247
+ end
248
+
249
+ def expand_tilde(path, home_root)
250
+ suffix = path == "~" ? "" : path[2..]
251
+ suffix.empty? ? home_root : join_relative(home_root, suffix)
252
+ end
253
+
254
+ def join_child_path(base, child, source_name)
255
+ child_path = normalize_separators(child.to_s)
256
+ fail ArgumentError, "#{source_name} child path must be relative: #{child.inspect}" if blank?(child_path)
257
+ fail ArgumentError, "#{source_name} child path must be relative: #{child.inspect}" if absolute_path?(child_path)
258
+ fail ArgumentError, "#{source_name} child path must be relative: #{child.inspect}" if invalid_relative_join_path?(child_path)
259
+
260
+ join_relative(base, child_path)
261
+ end
262
+
263
+ def join_relative(base, relative)
264
+ relative_path = normalize_separators(relative.to_s)
265
+ return ensure_absolute!(relative_path) if absolute_path?(relative_path)
266
+ fail ArgumentError, "Resolved path must not stay relative: #{relative.inspect}" if relative_path.empty?
267
+
268
+ joined = join_path(base, relative_path)
269
+ ensure_absolute!(joined)
270
+ end
271
+
272
+ def join_path(base, suffix)
273
+ cleaned_base = normalize_separators(base)
274
+ cleaned_suffix = normalize_separators(suffix).sub(%r{\A/+}, "")
275
+ return "/" if posix_root_path?(cleaned_base) && cleaned_suffix.empty?
276
+ return "/#{cleaned_suffix}" if posix_root_path?(cleaned_base)
277
+
278
+ cleaned_base = cleaned_base.sub(%r{/+\z}, "")
279
+
280
+ [cleaned_base, cleaned_suffix].reject(&:empty?).join("/")
281
+ end
282
+
283
+ def ensure_absolute!(path)
284
+ normalized = normalize_separators(path)
285
+ fail ArgumentError, "Resolved path must be absolute: #{path.inspect}" unless absolute_path?(normalized)
286
+
287
+ normalized
288
+ end
289
+
290
+ def absolute_path?(path)
291
+ return windows_absolute_path?(path) if @os == :windows
292
+
293
+ Pathname(path).absolute?
294
+ end
295
+
296
+ def windows_absolute_path?(path)
297
+ normalized = normalize_separators(path)
298
+ normalized.match?(WINDOWS_DRIVE_PATH) || normalized.match?(WINDOWS_UNC_PATH)
299
+ end
300
+
301
+ def invalid_relative_join_path?(path)
302
+ return false unless @os == :windows
303
+ return false if absolute_path?(path)
304
+
305
+ path.start_with?("/") || drive_relative_path?(path)
306
+ end
307
+
308
+ def drive_relative_path?(path)
309
+ path.match?(/\A[A-Za-z]:(?!\/)/)
310
+ end
311
+
312
+ def find_directory_candidate(candidates)
313
+ candidates.find { directory_path?(_1) }
314
+ end
315
+
316
+ def windows_home_fallback_suffix(env_key)
317
+ {
318
+ "APPDATA" => "AppData/Roaming",
319
+ "LOCALAPPDATA" => "AppData/Local"
320
+ }[env_key]
321
+ end
322
+
323
+ def optional_windows_xdg_env?(env_key, spec)
324
+ spec.fetch(:optional, false) && env_key.start_with?("XDG_")
325
+ end
326
+
327
+ def directory_path?(path)
328
+ path&.directory? || false
329
+ end
330
+
331
+ def normalize_separators(path)
332
+ return path.tr("\\", "/") if @os == :windows
333
+
334
+ path
335
+ end
336
+
337
+ def posix_root_path?(path)
338
+ @os != :windows && path.match?(%r{\A/+\z})
339
+ end
340
+
341
+ def no_paths_message(name, detail)
342
+ "No paths configured for #{name.inspect} on #{@os.inspect}: #{detail}"
343
+ end
344
+
345
+ def parse_verified_on(name, value)
346
+ return if value.nil?
347
+ fail ArgumentError, "Invalid verified_on for #{name.inspect}: #{value.inspect}" unless value.is_a?(String)
348
+
349
+ Date.iso8601(value)
350
+ rescue Date::Error
351
+ fail ArgumentError, "Invalid verified_on for #{name.inspect}: #{value.inspect}"
352
+ end
353
+
354
+ def blank?(value)
355
+ value.nil? || value.to_s.strip.empty?
356
+ end
357
+
358
+ def snapshot_value(value)
359
+ case value
360
+ when Hash
361
+ value.each_with_object({}) do |(key, nested), copy|
362
+ copy[snapshot_value(key)] = snapshot_value(nested)
363
+ end.freeze
364
+ when Array
365
+ value.map { snapshot_value(_1) }.freeze
366
+ when String
367
+ value.dup.freeze
368
+ else
369
+ value
370
+ end
371
+ end
372
+ end
373
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsHomedir
4
+ class UnknownAgent < Error; end
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentsHomedir
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "zeitwerk"
5
+
6
+ module AgentsHomedir
7
+ end
8
+
9
+ loader = Zeitwerk::Loader.for_gem
10
+ loader.setup
11
+ AgentsHomedir.const_set(:LOADER, loader)
12
+
13
+ module AgentsHomedir
14
+ DEFAULT_RESOLVER_MONITOR = Monitor.new
15
+ private_constant :DEFAULT_RESOLVER_MONITOR, :LOADER
16
+
17
+ class << self
18
+ # Returns the memoized default resolver built from the current environment.
19
+ # ENV and HOME are snapshotted on first access and reused for process lifetime.
20
+ #
21
+ # @return [AgentsHomedir::Resolver]
22
+ # @raise [ArgumentError] if the current host OS is unsupported
23
+ def default_resolver
24
+ return @default_resolver if @default_resolver
25
+
26
+ DEFAULT_RESOLVER_MONITOR.synchronize do
27
+ @default_resolver ||= Resolver.new
28
+ end
29
+ end
30
+
31
+ # Returns the configured home directory for the named agent.
32
+ #
33
+ # @param name [String, Symbol]
34
+ # @return [Pathname]
35
+ # @raise [AgentsHomedir::UnknownAgent] if the name is not registered
36
+ # @raise [AgentsHomedir::HomeNotResolvable] if HOME is missing, blank, or not absolute
37
+ def home(name)
38
+ default_resolver.home(name)
39
+ end
40
+
41
+ # Returns whether the named agent is currently installed.
42
+ #
43
+ # @param name [String, Symbol]
44
+ # @return [Boolean]
45
+ # @raise [AgentsHomedir::UnknownAgent] if the name is not registered
46
+ # @raise [AgentsHomedir::HomeNotResolvable] if HOME is missing, blank, or not absolute
47
+ def installed?(name)
48
+ default_resolver.installed?(name)
49
+ end
50
+
51
+ # Returns the named agent from the default registry.
52
+ #
53
+ # @param name [String, Symbol]
54
+ # @return [AgentsHomedir::Agent]
55
+ # @raise [AgentsHomedir::UnknownAgent] if the name is not registered
56
+ def [](name)
57
+ default_resolver[name]
58
+ end
59
+
60
+ # Returns all known agents in registry order.
61
+ #
62
+ # @return [Array<AgentsHomedir::Agent>]
63
+ def agents
64
+ default_resolver.agents
65
+ end
66
+
67
+ # Returns currently installed agents in registry order.
68
+ #
69
+ # @return [Array<AgentsHomedir::Agent>]
70
+ # @raise [AgentsHomedir::HomeNotResolvable] if HOME is missing, blank, or not absolute
71
+ def installed
72
+ default_resolver.installed
73
+ end
74
+
75
+ # Returns all known agent names in registry order.
76
+ #
77
+ # @return [Array<Symbol>]
78
+ def names
79
+ default_resolver.names
80
+ end
81
+
82
+ private
83
+
84
+ def loader
85
+ LOADER
86
+ end
87
+ end
88
+ end
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: agents_homedir
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Lucian Ghinda
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: zeitwerk
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.8'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.8'
26
+ - !ruby/object:Gem::Dependency
27
+ name: bundler
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '5'
36
+ type: :development
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '2.0'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '5'
46
+ - !ruby/object:Gem::Dependency
47
+ name: minitest
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '5.0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '5.0'
60
+ - !ruby/object:Gem::Dependency
61
+ name: rake
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '13.0'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '13.0'
74
+ description: Helpers for resolving and normalizing home directories used by AI coding
75
+ agents.
76
+ email:
77
+ - lucianghinda@users.noreply.github.com
78
+ executables: []
79
+ extensions: []
80
+ extra_rdoc_files: []
81
+ files:
82
+ - LICENSE.txt
83
+ - README.md
84
+ - lib/agents_homedir.rb
85
+ - lib/agents_homedir/agent.rb
86
+ - lib/agents_homedir/error.rb
87
+ - lib/agents_homedir/home_not_resolvable.rb
88
+ - lib/agents_homedir/registry.rb
89
+ - lib/agents_homedir/resolver.rb
90
+ - lib/agents_homedir/unknown_agent.rb
91
+ - lib/agents_homedir/version.rb
92
+ homepage: https://github.com/lucianghinda/agents_homedir
93
+ licenses:
94
+ - MIT
95
+ metadata:
96
+ source_code_uri: https://github.com/lucianghinda/agents_homedir
97
+ bug_tracker_uri: https://github.com/lucianghinda/agents_homedir/issues
98
+ rubygems_mfa_required: 'true'
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '3.2'
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubygems_version: 4.0.11
114
+ specification_version: 4
115
+ summary: Resolve home directories for AI coding agents.
116
+ test_files: []