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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +142 -0
- data/lib/agents_homedir/agent.rb +81 -0
- data/lib/agents_homedir/error.rb +5 -0
- data/lib/agents_homedir/home_not_resolvable.rb +5 -0
- data/lib/agents_homedir/registry.rb +115 -0
- data/lib/agents_homedir/resolver.rb +373 -0
- data/lib/agents_homedir/unknown_agent.rb +5 -0
- data/lib/agents_homedir/version.rb +5 -0
- data/lib/agents_homedir.rb +88 -0
- metadata +116 -0
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,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,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: []
|