agent_homedir 0.3.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 +7 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE.txt +21 -0
- data/README.md +177 -0
- data/doc/Agent/Homedir/Entry.md +24 -0
- data/doc/Agent/Homedir/Error.md +6 -0
- data/doc/Agent/Homedir/HomeNotResolvable.md +6 -0
- data/doc/Agent/Homedir/Resolver.md +45 -0
- data/doc/Agent/Homedir/UnknownAgent.md +6 -0
- data/doc/Agent/Homedir.md +60 -0
- data/doc/Agent.md +5 -0
- data/doc/CHANGELOG.md +40 -0
- data/doc/README.md +177 -0
- data/doc/index.csv +36 -0
- data/lib/agent/homedir/entry.rb +83 -0
- data/lib/agent/homedir/error.rb +7 -0
- data/lib/agent/homedir/home_not_resolvable.rb +7 -0
- data/lib/agent/homedir/registry.rb +117 -0
- data/lib/agent/homedir/resolver.rb +375 -0
- data/lib/agent/homedir/unknown_agent.rb +7 -0
- data/lib/agent/homedir/version.rb +7 -0
- data/lib/agent/homedir.rb +92 -0
- data/lib/agent_homedir.rb +3 -0
- data/llm.txt +60 -0
- metadata +158 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Homedir
|
|
7
|
+
class Entry < Data.define(:name, :label, :env_override, :verified_on)
|
|
8
|
+
# Equality and hash intentionally cover only public facts.
|
|
9
|
+
# Resolver-specific caches should key by resolver and agent name.
|
|
10
|
+
def initialize(name:, label:, env_override:, verified_on:, resolver:)
|
|
11
|
+
validate_name!(name)
|
|
12
|
+
validate_label!(label)
|
|
13
|
+
validate_env_override!(env_override)
|
|
14
|
+
validate_verified_on!(verified_on)
|
|
15
|
+
|
|
16
|
+
@resolver = resolver
|
|
17
|
+
|
|
18
|
+
super(
|
|
19
|
+
name: name,
|
|
20
|
+
label: snapshot_string(label),
|
|
21
|
+
env_override: snapshot_string(env_override),
|
|
22
|
+
verified_on:
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def home = @resolver.home(name)
|
|
27
|
+
|
|
28
|
+
def installed? = @resolver.installed?(name)
|
|
29
|
+
|
|
30
|
+
def candidates = @resolver.candidates(name)
|
|
31
|
+
|
|
32
|
+
def with(**changes)
|
|
33
|
+
return self if changes.empty?
|
|
34
|
+
|
|
35
|
+
unknown_members = changes.keys - self.class.members
|
|
36
|
+
fail ArgumentError, "Unknown member(s) for #{self.class}: #{unknown_members.map(&:inspect).join(', ')}" if unknown_members.any?
|
|
37
|
+
reject_resolver_bound_changes!(changes)
|
|
38
|
+
|
|
39
|
+
updated = to_h.merge(changes)
|
|
40
|
+
return self if updated == to_h
|
|
41
|
+
|
|
42
|
+
self.class.new(**updated, resolver: @resolver)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def reject_resolver_bound_changes!(changes)
|
|
48
|
+
[:name, :env_override].each do |field|
|
|
49
|
+
next unless changes.key?(field)
|
|
50
|
+
next if changes[field] == public_send(field)
|
|
51
|
+
|
|
52
|
+
fail ArgumentError, "#{field} is resolver-bound and cannot be changed via #with"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_name!(value)
|
|
57
|
+
fail ArgumentError, "name must be a Symbol" unless value.is_a?(Symbol)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_label!(value)
|
|
61
|
+
fail ArgumentError, "label must be a String" unless value.is_a?(String)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_env_override!(value)
|
|
65
|
+
return if value.nil? || value.is_a?(String)
|
|
66
|
+
|
|
67
|
+
fail ArgumentError, "env_override must be a String or nil"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_verified_on!(value)
|
|
71
|
+
return if value.nil? || value.is_a?(Date)
|
|
72
|
+
|
|
73
|
+
fail ArgumentError, "verified_on must be a Date or nil"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def snapshot_string(value)
|
|
77
|
+
return if value.nil?
|
|
78
|
+
|
|
79
|
+
value.dup.freeze
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Homedir
|
|
5
|
+
module Registry
|
|
6
|
+
class << self
|
|
7
|
+
def entries
|
|
8
|
+
@entries ||= deep_freeze(
|
|
9
|
+
{
|
|
10
|
+
claude_code: agent("Claude Code", env: "CLAUDE_CONFIG_DIR", paths: "~/.claude", verified_on: "2026-08-05"),
|
|
11
|
+
codex: agent("Codex CLI", env: "CODEX_HOME", paths: "~/.codex", verified_on: "2026-07-21"),
|
|
12
|
+
gemini: agent("Gemini CLI", paths: "~/.gemini"),
|
|
13
|
+
antigravity_cli: agent("Antigravity CLI", paths: "~/.gemini/antigravity-cli"),
|
|
14
|
+
antigravity_ide: agent("Antigravity IDE", paths: "~/.gemini/antigravity-ide"),
|
|
15
|
+
antigravity_app: agent("Antigravity App", paths: "~/.gemini/antigravity"),
|
|
16
|
+
qwen: agent("Qwen Code", paths: "~/.qwen"),
|
|
17
|
+
pi: agent("Pi", env: "PI_CODING_AGENT_DIR", paths: "~/.pi/agent", verified_on: "2026-07-21"),
|
|
18
|
+
amp: agent("Amp", paths: [xdg_data("amp")], verified_on: "2026-07-21"),
|
|
19
|
+
opencode: agent(
|
|
20
|
+
"OpenCode",
|
|
21
|
+
env: "OPENCODE_DATA_DIR",
|
|
22
|
+
paths: {
|
|
23
|
+
macos: [xdg_data("opencode"), "~/Library/Application Support/opencode"],
|
|
24
|
+
linux: [xdg_data("opencode")],
|
|
25
|
+
windows: [
|
|
26
|
+
windows_env("XDG_DATA_HOME", "opencode", optional: true),
|
|
27
|
+
windows_env("APPDATA", "opencode"),
|
|
28
|
+
windows_env("LOCALAPPDATA", "opencode"),
|
|
29
|
+
"~/.local/share/opencode"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
verified_on: "2026-07-21"
|
|
33
|
+
),
|
|
34
|
+
cursor: agent("Cursor", paths: "~/.cursor", verified_on: "2026-07-21"),
|
|
35
|
+
cursor_ide: agent(
|
|
36
|
+
"Cursor IDE",
|
|
37
|
+
paths: {
|
|
38
|
+
macos: "~/Library/Application Support/Cursor",
|
|
39
|
+
linux: xdg_config("Cursor"),
|
|
40
|
+
windows: windows_env("APPDATA", "Cursor")
|
|
41
|
+
}
|
|
42
|
+
),
|
|
43
|
+
github_copilot_cli: agent("GitHub Copilot CLI", paths: "~/.copilot"),
|
|
44
|
+
vscode_copilot_chat: agent(
|
|
45
|
+
"VS Code Copilot Chat",
|
|
46
|
+
paths: {
|
|
47
|
+
macos: "~/Library/Application Support/Code/User",
|
|
48
|
+
linux: xdg_config("Code/User"),
|
|
49
|
+
windows: windows_env("APPDATA", "Code/User")
|
|
50
|
+
}
|
|
51
|
+
),
|
|
52
|
+
cline: agent("Cline", paths: "~/.cline"),
|
|
53
|
+
cline_vscode: agent(
|
|
54
|
+
"Cline for VS Code",
|
|
55
|
+
paths: {
|
|
56
|
+
macos: "~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev",
|
|
57
|
+
linux: xdg_config("Code/User/globalStorage/saoudrizwan.claude-dev"),
|
|
58
|
+
windows: windows_env("APPDATA", "Code/User/globalStorage/saoudrizwan.claude-dev")
|
|
59
|
+
}
|
|
60
|
+
),
|
|
61
|
+
grok_build: agent("Grok Build", paths: "~/.grok"),
|
|
62
|
+
vibe: agent("Vibe", paths: "~/.vibe"),
|
|
63
|
+
muse: agent("Muse Code", paths: [xdg_data("muse")]),
|
|
64
|
+
prime_agent: agent("Prime Agent", paths: "~/.prime/agent"),
|
|
65
|
+
deepseek_harness: agent("DeepSeek Harness", env: "DSH_HOME", paths: "~/.dsh"),
|
|
66
|
+
hermes: agent("Hermes Agent", env: "HERMES_HOME", paths: "~/.hermes"),
|
|
67
|
+
factory_droid: agent("Factory Droid", paths: "~/.factory"),
|
|
68
|
+
devin_cli: agent("Devin CLI", paths: "~/.config/devin"),
|
|
69
|
+
devin_desktop: agent("Devin Desktop", paths: "~/.codeium/windsurf")
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def agent(label, env: nil, paths:, verified_on: nil)
|
|
77
|
+
{
|
|
78
|
+
label:,
|
|
79
|
+
env:,
|
|
80
|
+
paths:,
|
|
81
|
+
verified_on:
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def xdg_config(path)
|
|
86
|
+
{xdg: :config, path:}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def xdg_data(path)
|
|
90
|
+
{xdg: :data, path:}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def windows_env(env_key, path, optional: false)
|
|
94
|
+
spec = {windows_env: env_key, path:}
|
|
95
|
+
optional ? spec.merge(optional: true) : spec
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def deep_freeze(value)
|
|
99
|
+
case value
|
|
100
|
+
when Hash
|
|
101
|
+
value.each_with_object({}) do |(key, nested), copy|
|
|
102
|
+
copy[deep_freeze(key)] = deep_freeze(nested)
|
|
103
|
+
end.freeze
|
|
104
|
+
when Array
|
|
105
|
+
value.map { deep_freeze(_1) }.freeze
|
|
106
|
+
when String
|
|
107
|
+
value.dup.freeze
|
|
108
|
+
else
|
|
109
|
+
value
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private_constant :Registry
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "agent/homedir"
|
|
4
|
+
require "date"
|
|
5
|
+
require "monitor"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "rbconfig"
|
|
8
|
+
|
|
9
|
+
module Agent
|
|
10
|
+
module Homedir
|
|
11
|
+
class Resolver
|
|
12
|
+
VALID_OSES = %i[macos linux windows].freeze
|
|
13
|
+
WINDOWS_DRIVE_PATH = /\A[A-Za-z]:[\/\\]/.freeze
|
|
14
|
+
WINDOWS_UNC_PATH = /\A[\/\\]{2}[^\/\\]+[\/\\]+[^\/\\]+/.freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def default_os
|
|
18
|
+
@default_os ||= detect_os(RbConfig::CONFIG["host_os"])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def detect_os(host_os)
|
|
24
|
+
case host_os
|
|
25
|
+
when /darwin/i
|
|
26
|
+
:macos
|
|
27
|
+
when /linux/i
|
|
28
|
+
:linux
|
|
29
|
+
when /mswin|mingw/i
|
|
30
|
+
:windows
|
|
31
|
+
else
|
|
32
|
+
fail ArgumentError, "Unsupported host OS: #{host_os.inspect}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(env: ENV.to_h, home: ENV["HOME"], os: self.class.default_os, entries: Registry.entries)
|
|
38
|
+
fail ArgumentError, "Invalid OS #{os.inspect}; expected one of #{VALID_OSES.inspect}" unless VALID_OSES.include?(os)
|
|
39
|
+
|
|
40
|
+
@env = snapshot_value(env)
|
|
41
|
+
@home = snapshot_value(home)
|
|
42
|
+
@os = os
|
|
43
|
+
@entries = snapshot_value(entries)
|
|
44
|
+
@memoization_monitor = Monitor.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def home(name)
|
|
48
|
+
ensure_home_root!
|
|
49
|
+
|
|
50
|
+
override = env_override_for(name)
|
|
51
|
+
return override if override
|
|
52
|
+
|
|
53
|
+
available_candidates = candidates(name)
|
|
54
|
+
find_directory_candidate(available_candidates) || available_candidates.first
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def installed?(name)
|
|
58
|
+
ensure_home_root!
|
|
59
|
+
|
|
60
|
+
override = env_override_for(name)
|
|
61
|
+
return true if directory_path?(override)
|
|
62
|
+
|
|
63
|
+
candidates(name).any? { directory_path?(_1) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def candidates(name)
|
|
67
|
+
home_root = ensure_home_root!
|
|
68
|
+
specs = path_specs_for(name)
|
|
69
|
+
expanded = expand_specs(specs, home_root)
|
|
70
|
+
fail ArgumentError, no_paths_message(name, "current os paths are empty") if expanded.empty?
|
|
71
|
+
|
|
72
|
+
expanded.freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def [](name)
|
|
76
|
+
key = name.to_sym
|
|
77
|
+
entry = entry_for(key)
|
|
78
|
+
|
|
79
|
+
Entry.new(
|
|
80
|
+
name: key,
|
|
81
|
+
label: entry.fetch(:label),
|
|
82
|
+
env_override: entry[:env],
|
|
83
|
+
verified_on: parse_verified_on(key, entry[:verified_on]),
|
|
84
|
+
resolver: self
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def names
|
|
89
|
+
return @names if @names
|
|
90
|
+
|
|
91
|
+
@memoization_monitor.synchronize do
|
|
92
|
+
@names ||= @entries.keys.freeze
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def agents
|
|
97
|
+
return @agents if @agents
|
|
98
|
+
|
|
99
|
+
@memoization_monitor.synchronize do
|
|
100
|
+
@agents ||= names.map { self[_1] }.freeze
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def installed
|
|
105
|
+
agents.select(&:installed?).freeze
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def entry_for(name)
|
|
111
|
+
key = name.to_sym
|
|
112
|
+
entry = @entries[key]
|
|
113
|
+
return entry if entry
|
|
114
|
+
|
|
115
|
+
valid = @entries.keys.map(&:inspect).sort.join(", ")
|
|
116
|
+
fail UnknownAgent, "Unknown agent #{name.inspect}. Valid agents: #{valid}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def path_specs_for(name)
|
|
120
|
+
specs = entry_for(name).fetch(:paths)
|
|
121
|
+
fail ArgumentError, no_paths_message(name, "paths are nil") if specs.nil?
|
|
122
|
+
fail ArgumentError, no_paths_message(name, "paths are empty") if specs.respond_to?(:empty?) && specs.empty?
|
|
123
|
+
|
|
124
|
+
specs
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def env_override_for(name)
|
|
128
|
+
entry = entry_for(name)
|
|
129
|
+
env_key = entry[:env]
|
|
130
|
+
return unless env_key
|
|
131
|
+
|
|
132
|
+
raw = @env[env_key]
|
|
133
|
+
return if blank?(raw)
|
|
134
|
+
|
|
135
|
+
Pathname(resolve_path(raw, ensure_home_root!, allow_relative: true))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def ensure_home_root!
|
|
139
|
+
raw_home = @home.to_s
|
|
140
|
+
fail HomeNotResolvable, "HOME is not resolvable" if blank?(raw_home)
|
|
141
|
+
|
|
142
|
+
normalized = normalize_separators(raw_home)
|
|
143
|
+
fail HomeNotResolvable, "HOME is not resolvable" unless absolute_path?(normalized)
|
|
144
|
+
|
|
145
|
+
normalized
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def expand_specs(specs, home_root)
|
|
149
|
+
return specs.flat_map { expand_spec(_1, home_root) } if specs.is_a?(Array)
|
|
150
|
+
|
|
151
|
+
expand_spec(specs, home_root)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def expand_spec(spec, home_root)
|
|
155
|
+
case spec
|
|
156
|
+
when Array
|
|
157
|
+
expand_specs(spec, home_root)
|
|
158
|
+
when String
|
|
159
|
+
[Pathname(resolve_registry_path(spec, home_root))]
|
|
160
|
+
when Hash
|
|
161
|
+
if os_selector?(spec)
|
|
162
|
+
selected = spec[@os]
|
|
163
|
+
selected ? expand_spec(selected, home_root) : []
|
|
164
|
+
elsif spec.key?(:xdg)
|
|
165
|
+
[Pathname(resolve_xdg_path(spec, home_root))]
|
|
166
|
+
elsif spec.key?(:windows_env)
|
|
167
|
+
resolved = resolve_windows_env_path(spec)
|
|
168
|
+
resolved ? [Pathname(resolved)] : []
|
|
169
|
+
else
|
|
170
|
+
fail ArgumentError, "Unsupported path spec #{spec.inspect}"
|
|
171
|
+
end
|
|
172
|
+
else
|
|
173
|
+
fail ArgumentError, "Unsupported path spec #{spec.inspect}"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def os_selector?(spec)
|
|
178
|
+
!(spec.keys & VALID_OSES).empty?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def resolve_registry_path(spec, home_root)
|
|
182
|
+
if spec == "~" || spec.start_with?("~/", "~\\")
|
|
183
|
+
expand_tilde(spec, home_root)
|
|
184
|
+
else
|
|
185
|
+
normalized = normalize_separators(spec)
|
|
186
|
+
ensure_absolute!(normalized)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def resolve_xdg_path(spec, home_root)
|
|
191
|
+
base =
|
|
192
|
+
case spec.fetch(:xdg)
|
|
193
|
+
when :config
|
|
194
|
+
resolve_xdg_base("XDG_CONFIG_HOME", join_path(home_root, ".config"))
|
|
195
|
+
when :data
|
|
196
|
+
resolve_xdg_base("XDG_DATA_HOME", join_path(home_root, ".local/share"))
|
|
197
|
+
else
|
|
198
|
+
fail ArgumentError, "Unsupported XDG base #{spec[:xdg].inspect}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
join_child_path(base, spec.fetch(:path), "XDG")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def resolve_xdg_base(env_key, fallback)
|
|
205
|
+
value = @env[env_key]
|
|
206
|
+
return fallback if blank?(value)
|
|
207
|
+
|
|
208
|
+
normalized = normalize_separators(value.to_s)
|
|
209
|
+
return fallback unless absolute_path?(normalized)
|
|
210
|
+
|
|
211
|
+
normalized
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def resolve_windows_env_path(spec)
|
|
215
|
+
env_key = spec.fetch(:windows_env)
|
|
216
|
+
base_value = @env[env_key]
|
|
217
|
+
|
|
218
|
+
if blank?(base_value)
|
|
219
|
+
fallback_suffix = windows_home_fallback_suffix(env_key)
|
|
220
|
+
base_value = join_child_path(ensure_home_root!, fallback_suffix, env_key) if fallback_suffix
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
return if blank?(base_value) && spec.fetch(:optional, false)
|
|
224
|
+
fail ArgumentError, "Missing windows_env #{env_key} for #{spec.inspect}" if blank?(base_value)
|
|
225
|
+
|
|
226
|
+
if optional_windows_xdg_env?(env_key, spec)
|
|
227
|
+
normalized = normalize_separators(base_value.to_s)
|
|
228
|
+
return unless absolute_path?(normalized)
|
|
229
|
+
|
|
230
|
+
base_value = normalized
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
base = resolve_path(base_value, ensure_home_root!, allow_relative: false)
|
|
234
|
+
join_child_path(base, spec.fetch(:path), env_key)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def resolve_path(raw_path, home_root, allow_relative:)
|
|
238
|
+
normalized = normalize_separators(raw_path.to_s)
|
|
239
|
+
return expand_tilde(normalized, home_root) if normalized == "~" || normalized.start_with?("~/", "~\\")
|
|
240
|
+
return normalized if absolute_path?(normalized)
|
|
241
|
+
fail ArgumentError, "Resolved path must be an ordinary relative path: #{raw_path.inspect}" if invalid_relative_join_path?(normalized)
|
|
242
|
+
|
|
243
|
+
if allow_relative
|
|
244
|
+
join_relative(home_root, normalized)
|
|
245
|
+
else
|
|
246
|
+
fail ArgumentError, "Resolved path must be absolute: #{raw_path.inspect}"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def expand_tilde(path, home_root)
|
|
251
|
+
suffix = path == "~" ? "" : path[2..]
|
|
252
|
+
suffix.empty? ? home_root : join_relative(home_root, suffix)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def join_child_path(base, child, source_name)
|
|
256
|
+
child_path = normalize_separators(child.to_s)
|
|
257
|
+
fail ArgumentError, "#{source_name} child path must be relative: #{child.inspect}" if blank?(child_path)
|
|
258
|
+
fail ArgumentError, "#{source_name} child path must be relative: #{child.inspect}" if absolute_path?(child_path)
|
|
259
|
+
fail ArgumentError, "#{source_name} child path must be relative: #{child.inspect}" if invalid_relative_join_path?(child_path)
|
|
260
|
+
|
|
261
|
+
join_relative(base, child_path)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def join_relative(base, relative)
|
|
265
|
+
relative_path = normalize_separators(relative.to_s)
|
|
266
|
+
return ensure_absolute!(relative_path) if absolute_path?(relative_path)
|
|
267
|
+
fail ArgumentError, "Resolved path must not stay relative: #{relative.inspect}" if relative_path.empty?
|
|
268
|
+
|
|
269
|
+
joined = join_path(base, relative_path)
|
|
270
|
+
ensure_absolute!(joined)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def join_path(base, suffix)
|
|
274
|
+
cleaned_base = normalize_separators(base)
|
|
275
|
+
cleaned_suffix = normalize_separators(suffix).sub(%r{\A/+}, "")
|
|
276
|
+
return "/" if posix_root_path?(cleaned_base) && cleaned_suffix.empty?
|
|
277
|
+
return "/#{cleaned_suffix}" if posix_root_path?(cleaned_base)
|
|
278
|
+
|
|
279
|
+
cleaned_base = cleaned_base.sub(%r{/+\z}, "")
|
|
280
|
+
|
|
281
|
+
[cleaned_base, cleaned_suffix].reject(&:empty?).join("/")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def ensure_absolute!(path)
|
|
285
|
+
normalized = normalize_separators(path)
|
|
286
|
+
fail ArgumentError, "Resolved path must be absolute: #{path.inspect}" unless absolute_path?(normalized)
|
|
287
|
+
|
|
288
|
+
normalized
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def absolute_path?(path)
|
|
292
|
+
return windows_absolute_path?(path) if @os == :windows
|
|
293
|
+
|
|
294
|
+
Pathname(path).absolute?
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def windows_absolute_path?(path)
|
|
298
|
+
normalized = normalize_separators(path)
|
|
299
|
+
normalized.match?(WINDOWS_DRIVE_PATH) || normalized.match?(WINDOWS_UNC_PATH)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def invalid_relative_join_path?(path)
|
|
303
|
+
return false unless @os == :windows
|
|
304
|
+
return false if absolute_path?(path)
|
|
305
|
+
|
|
306
|
+
path.start_with?("/") || drive_relative_path?(path)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def drive_relative_path?(path)
|
|
310
|
+
path.match?(/\A[A-Za-z]:(?!\/)/)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def find_directory_candidate(candidates)
|
|
314
|
+
candidates.find { directory_path?(_1) }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def windows_home_fallback_suffix(env_key)
|
|
318
|
+
{
|
|
319
|
+
"APPDATA" => "AppData/Roaming",
|
|
320
|
+
"LOCALAPPDATA" => "AppData/Local"
|
|
321
|
+
}[env_key]
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def optional_windows_xdg_env?(env_key, spec)
|
|
325
|
+
spec.fetch(:optional, false) && env_key.start_with?("XDG_")
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def directory_path?(path)
|
|
329
|
+
path&.directory? || false
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def normalize_separators(path)
|
|
333
|
+
return path.tr("\\", "/") if @os == :windows
|
|
334
|
+
|
|
335
|
+
path
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def posix_root_path?(path)
|
|
339
|
+
@os != :windows && path.match?(%r{\A/+\z})
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def no_paths_message(name, detail)
|
|
343
|
+
"No paths configured for #{name.inspect} on #{@os.inspect}: #{detail}"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def parse_verified_on(name, value)
|
|
347
|
+
return if value.nil?
|
|
348
|
+
fail ArgumentError, "Invalid verified_on for #{name.inspect}: #{value.inspect}" unless value.is_a?(String)
|
|
349
|
+
|
|
350
|
+
Date.iso8601(value)
|
|
351
|
+
rescue Date::Error
|
|
352
|
+
fail ArgumentError, "Invalid verified_on for #{name.inspect}: #{value.inspect}"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def blank?(value)
|
|
356
|
+
value.nil? || value.to_s.strip.empty?
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def snapshot_value(value)
|
|
360
|
+
case value
|
|
361
|
+
when Hash
|
|
362
|
+
value.each_with_object({}) do |(key, nested), copy|
|
|
363
|
+
copy[snapshot_value(key)] = snapshot_value(nested)
|
|
364
|
+
end.freeze
|
|
365
|
+
when Array
|
|
366
|
+
value.map { snapshot_value(_1) }.freeze
|
|
367
|
+
when String
|
|
368
|
+
value.dup.freeze
|
|
369
|
+
else
|
|
370
|
+
value
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "zeitwerk"
|
|
5
|
+
|
|
6
|
+
module Agent
|
|
7
|
+
module Homedir
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
loader = Zeitwerk::Loader.for_gem_extension(Agent)
|
|
12
|
+
loader.setup
|
|
13
|
+
Agent::Homedir.const_set(:LOADER, loader)
|
|
14
|
+
|
|
15
|
+
module Agent
|
|
16
|
+
module Homedir
|
|
17
|
+
DEFAULT_RESOLVER_MONITOR = Monitor.new
|
|
18
|
+
private_constant :DEFAULT_RESOLVER_MONITOR, :LOADER
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Returns the memoized default resolver built from the current environment.
|
|
22
|
+
# ENV and HOME are snapshotted on first access and reused for process lifetime.
|
|
23
|
+
#
|
|
24
|
+
# @return [Agent::Homedir::Resolver]
|
|
25
|
+
# @raise [ArgumentError] if the current host OS is unsupported
|
|
26
|
+
def default_resolver
|
|
27
|
+
return @default_resolver if @default_resolver
|
|
28
|
+
|
|
29
|
+
DEFAULT_RESOLVER_MONITOR.synchronize do
|
|
30
|
+
@default_resolver ||= Resolver.new
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the configured home directory for the named agent.
|
|
35
|
+
#
|
|
36
|
+
# @param name [String, Symbol]
|
|
37
|
+
# @return [Pathname]
|
|
38
|
+
# @raise [Agent::Homedir::UnknownAgent] if the name is not registered
|
|
39
|
+
# @raise [Agent::Homedir::HomeNotResolvable] if HOME is missing, blank, or not absolute
|
|
40
|
+
def home(name)
|
|
41
|
+
default_resolver.home(name)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns whether the named agent is currently installed.
|
|
45
|
+
#
|
|
46
|
+
# @param name [String, Symbol]
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
# @raise [Agent::Homedir::UnknownAgent] if the name is not registered
|
|
49
|
+
# @raise [Agent::Homedir::HomeNotResolvable] if HOME is missing, blank, or not absolute
|
|
50
|
+
def installed?(name)
|
|
51
|
+
default_resolver.installed?(name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns the named agent from the default registry.
|
|
55
|
+
#
|
|
56
|
+
# @param name [String, Symbol]
|
|
57
|
+
# @return [Agent::Homedir::Entry]
|
|
58
|
+
# @raise [Agent::Homedir::UnknownAgent] if the name is not registered
|
|
59
|
+
def [](name)
|
|
60
|
+
default_resolver[name]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returns all known agents in registry order.
|
|
64
|
+
#
|
|
65
|
+
# @return [Array<Agent::Homedir::Entry>]
|
|
66
|
+
def agents
|
|
67
|
+
default_resolver.agents
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns currently installed agents in registry order.
|
|
71
|
+
#
|
|
72
|
+
# @return [Array<Agent::Homedir::Entry>]
|
|
73
|
+
# @raise [Agent::Homedir::HomeNotResolvable] if HOME is missing, blank, or not absolute
|
|
74
|
+
def installed
|
|
75
|
+
default_resolver.installed
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns all known agent names in registry order.
|
|
79
|
+
#
|
|
80
|
+
# @return [Array<Symbol>]
|
|
81
|
+
def names
|
|
82
|
+
default_resolver.names
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def loader
|
|
88
|
+
LOADER
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|