agent_settings 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/AGENTS.md +244 -0
- data/CHANGELOG.md +11 -0
- data/CLAUDE.md +95 -0
- data/README.md +177 -0
- data/lib/agent_settings/adapters/claude.rb +170 -0
- data/lib/agent_settings/adapters/codex.rb +157 -0
- data/lib/agent_settings/adapters/opencode.rb +185 -0
- data/lib/agent_settings/agent_config_path.rb +51 -0
- data/lib/agent_settings/env_override.rb +61 -0
- data/lib/agent_settings/location.rb +38 -0
- data/lib/agent_settings/location_discovery.rb +119 -0
- data/lib/agent_settings/registry.rb +39 -0
- data/lib/agent_settings/version.rb +6 -0
- data/lib/agent_settings.rb +152 -0
- data/llm.md +165 -0
- metadata +77 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentSettings
|
|
4
|
+
module Adapters
|
|
5
|
+
# Adapter for Codex agent configuration.
|
|
6
|
+
#
|
|
7
|
+
# Codex stores configuration in TOML files:
|
|
8
|
+
#
|
|
9
|
+
# - Global: ~/.codex/config.toml (user-level config)
|
|
10
|
+
# - Project: .codex/config.toml (project-level config, trusted only)
|
|
11
|
+
# - System: /etc/codex/config.toml (system-level config)
|
|
12
|
+
#
|
|
13
|
+
# Codex has a unique "trust" model - project config is only used
|
|
14
|
+
# for trusted projects. Untrusted projects will have a warning
|
|
15
|
+
# and no project layer.
|
|
16
|
+
#
|
|
17
|
+
# Environment variable CODEX_HOME can override the home directory
|
|
18
|
+
# where the global config is located.
|
|
19
|
+
#
|
|
20
|
+
# @example Trusted project
|
|
21
|
+
# adapter = Codex.new
|
|
22
|
+
# layers = adapter.layers(dir: "/work/my_app", env: ENV, trusted: true)
|
|
23
|
+
# layers.count #=> includes project layer
|
|
24
|
+
#
|
|
25
|
+
# @example Untrusted project
|
|
26
|
+
# layers = adapter.layers(dir: "/untrusted", env: ENV, trusted: false)
|
|
27
|
+
# adapter.warnings(...) #=> ["Project config ignored for untrusted project"]
|
|
28
|
+
class Codex
|
|
29
|
+
# Get all config layers for Codex.
|
|
30
|
+
#
|
|
31
|
+
# Returns an array of Location objects for global, system (if exists),
|
|
32
|
+
# and project (if trusted) configs. Project config is excluded for
|
|
33
|
+
# untrusted projects.
|
|
34
|
+
#
|
|
35
|
+
# @param dir [String] project directory path
|
|
36
|
+
# @param env [Hash] environment variables hash (CODEX_HOME is used)
|
|
37
|
+
# @param trusted [Boolean] whether the project is trusted
|
|
38
|
+
# @return [Array<Location>] config layers in precedence order
|
|
39
|
+
def layers(dir:, env:, trusted:)
|
|
40
|
+
build_layers(dir, env, trusted)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Get environment variable overrides for Codex.
|
|
44
|
+
#
|
|
45
|
+
# Checks for CODEX_HOME environment variable. When set, it changes
|
|
46
|
+
# the base directory for the global config (from ~ to the specified path).
|
|
47
|
+
#
|
|
48
|
+
# @param dir [String] project directory path (unused)
|
|
49
|
+
# @param env [Hash] environment variables hash
|
|
50
|
+
# @param trusted [Boolean] whether the project is trusted (unused)
|
|
51
|
+
# @return [Array<EnvOverride>] detected environment overrides
|
|
52
|
+
def env_overrides(dir:, env:, trusted:) # rubocop:disable Lint/UnusedMethodArgument
|
|
53
|
+
build_env_overrides(env)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get warnings for Codex configuration.
|
|
57
|
+
#
|
|
58
|
+
# Returns a warning if the project is untrusted but has a config file.
|
|
59
|
+
# This helps users understand why their project config is being ignored.
|
|
60
|
+
#
|
|
61
|
+
# @param dir [String] project directory path
|
|
62
|
+
# @param env [Hash] environment variables hash (unused)
|
|
63
|
+
# @param trusted [Boolean] whether the project is trusted
|
|
64
|
+
# @return [Array<String>] warning messages
|
|
65
|
+
def warnings(dir:, env:, trusted:) # rubocop:disable Lint/UnusedMethodArgument
|
|
66
|
+
warnings = []
|
|
67
|
+
if !trusted && File.exist?(File.join(dir, ".codex", "config.toml"))
|
|
68
|
+
warnings << "Project config ignored for untrusted project"
|
|
69
|
+
end
|
|
70
|
+
warnings
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Build the list of config layers.
|
|
76
|
+
#
|
|
77
|
+
# Creates Location objects for global, system (if exists), and
|
|
78
|
+
# project (if trusted) configs. CODEX_HOME can override the
|
|
79
|
+
# home directory for global config location.
|
|
80
|
+
#
|
|
81
|
+
# @param dir [String] project directory path
|
|
82
|
+
# @param env [Hash] environment variables hash
|
|
83
|
+
# @param trusted [Boolean] whether the project is trusted
|
|
84
|
+
# @return [Array<Location>] config layers with active flag set
|
|
85
|
+
def build_layers(dir, env, trusted)
|
|
86
|
+
home = env["CODEX_HOME"] || Dir.home
|
|
87
|
+
global_path = File.join(home, ".codex", "config.toml")
|
|
88
|
+
system_path = "/etc/codex/config.toml"
|
|
89
|
+
project_path = File.join(dir, ".codex", "config.toml")
|
|
90
|
+
|
|
91
|
+
layers = []
|
|
92
|
+
layers << build_layer(:global, "user", global_path)
|
|
93
|
+
layers << build_layer(:system, "system", system_path) if File.exist?(system_path)
|
|
94
|
+
|
|
95
|
+
layers << build_layer(:project, "file", project_path) if trusted
|
|
96
|
+
|
|
97
|
+
mark_active(layers)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Build environment variable overrides.
|
|
101
|
+
#
|
|
102
|
+
# Checks if CODEX_HOME is set and creates an EnvOverride for it.
|
|
103
|
+
# The override's path is the config.toml file within the specified
|
|
104
|
+
# home directory's .codex subdirectory.
|
|
105
|
+
#
|
|
106
|
+
# @param env [Hash] environment variables hash
|
|
107
|
+
# @return [Array<EnvOverride>] detected overrides
|
|
108
|
+
def build_env_overrides(env) # rubocop:disable Metrics/MethodLength -- constructs EnvOverride with all required attributes
|
|
109
|
+
overrides = []
|
|
110
|
+
|
|
111
|
+
if (value = env["CODEX_HOME"])
|
|
112
|
+
path = File.join(value, ".codex", "config.toml")
|
|
113
|
+
overrides << EnvOverride.new(
|
|
114
|
+
agent: :codex,
|
|
115
|
+
name: "CODEX_HOME",
|
|
116
|
+
value: value,
|
|
117
|
+
path: path,
|
|
118
|
+
exists: File.exist?(path),
|
|
119
|
+
active: File.exist?(path),
|
|
120
|
+
note: "Home directory override"
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
overrides
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Build a Location object for a config layer.
|
|
128
|
+
#
|
|
129
|
+
# @param scope [Symbol] the scope (:global, :project, :system)
|
|
130
|
+
# @param source [String] where this config comes from
|
|
131
|
+
# @param path [String] the file path
|
|
132
|
+
# @return [Location] a Location object (not yet marked active)
|
|
133
|
+
def build_layer(scope, source, path)
|
|
134
|
+
Location.new(
|
|
135
|
+
agent: :codex,
|
|
136
|
+
scope: scope,
|
|
137
|
+
source: source,
|
|
138
|
+
path: path,
|
|
139
|
+
exists: File.exist?(path),
|
|
140
|
+
active: false,
|
|
141
|
+
note: nil
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Mark the first existing layer as active.
|
|
146
|
+
#
|
|
147
|
+
# @param layers [Array<Location>] config layers
|
|
148
|
+
# @return [Array<Location>] layers with active flag set
|
|
149
|
+
def mark_active(layers)
|
|
150
|
+
found = layers.find(&:exists?)
|
|
151
|
+
layers.map do |layer|
|
|
152
|
+
layer.with(active: layer == found)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentSettings
|
|
4
|
+
module Adapters
|
|
5
|
+
# Adapter for OpenCode agent configuration.
|
|
6
|
+
#
|
|
7
|
+
# OpenCode stores configuration in JSON files:
|
|
8
|
+
#
|
|
9
|
+
# - Global: ~/.config/opencode/opencode.json (user-level config)
|
|
10
|
+
# - Project: opencode.json (discovered by walking up from project directory)
|
|
11
|
+
#
|
|
12
|
+
# OpenCode supports multiple environment variable overrides:
|
|
13
|
+
# - OPENCODE_CONFIG: Direct path to config file
|
|
14
|
+
# - OPENCODE_CONFIG_DIR: Directory containing opencode.json
|
|
15
|
+
# - OPENCODE_CONFIG_CONTENT: Inline JSON config content
|
|
16
|
+
#
|
|
17
|
+
# The project config uses discovery - it walks up from the provided
|
|
18
|
+
# directory looking for opencode.json, similar to how git finds .git.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# adapter = Opencode.new
|
|
22
|
+
# layers = adapter.layers(dir: "/work/my_app", env: ENV, trusted: true)
|
|
23
|
+
# env_overrides = adapter.env_overrides(dir: "/work/my_app", env: ENV, trusted: true)
|
|
24
|
+
class Opencode
|
|
25
|
+
# Get all config layers for OpenCode.
|
|
26
|
+
#
|
|
27
|
+
# Returns an array of Location objects for global and project
|
|
28
|
+
# configs. Project config is discovered by walking up the directory
|
|
29
|
+
# tree from the provided dir.
|
|
30
|
+
#
|
|
31
|
+
# @param dir [String] project directory path (starting point for discovery)
|
|
32
|
+
# @param env [Hash] environment variables hash (unused for layers)
|
|
33
|
+
# @param trusted [Boolean] whether the project is trusted (unused)
|
|
34
|
+
# @return [Array<Location>] config layers in precedence order
|
|
35
|
+
def layers(dir:, env:, trusted:) # rubocop:disable Lint/UnusedMethodArgument
|
|
36
|
+
build_layers(dir)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get environment variable overrides for OpenCode.
|
|
40
|
+
#
|
|
41
|
+
# Checks for three environment variables:
|
|
42
|
+
# - OPENCODE_CONFIG: Path to config file (active if file exists)
|
|
43
|
+
# - OPENCODE_CONFIG_DIR: Directory containing opencode.json
|
|
44
|
+
# - OPENCODE_CONFIG_CONTENT: Inline JSON (always active if set)
|
|
45
|
+
#
|
|
46
|
+
# @param dir [String] project directory path (unused)
|
|
47
|
+
# @param env [Hash] environment variables hash
|
|
48
|
+
# @param trusted [Boolean] whether the project is trusted (unused)
|
|
49
|
+
# @return [Array<EnvOverride>] detected environment overrides
|
|
50
|
+
def env_overrides(dir:, env:, trusted:) # rubocop:disable Lint/UnusedMethodArgument
|
|
51
|
+
build_env_overrides(env)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Get warnings for OpenCode configuration.
|
|
55
|
+
#
|
|
56
|
+
# OpenCode doesn't produce any warnings currently.
|
|
57
|
+
#
|
|
58
|
+
# @return [Array<String>] empty array
|
|
59
|
+
def warnings(*) = []
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Build the list of config layers.
|
|
64
|
+
#
|
|
65
|
+
# Creates Location objects for global and project configs.
|
|
66
|
+
# Project config is discovered by walking up the directory tree.
|
|
67
|
+
#
|
|
68
|
+
# @param dir [String] project directory path
|
|
69
|
+
# @return [Array<Location>] config layers with active flag set
|
|
70
|
+
def build_layers(dir)
|
|
71
|
+
home = Dir.home
|
|
72
|
+
global_path = File.join(home, ".config", "opencode", "opencode.json")
|
|
73
|
+
project_path = discover_project_config(dir)
|
|
74
|
+
|
|
75
|
+
layers = []
|
|
76
|
+
layers << build_layer(:global, "user", global_path)
|
|
77
|
+
layers << build_layer(:project, "file", project_path) if project_path
|
|
78
|
+
|
|
79
|
+
mark_active(layers)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Discover project config by walking up the directory tree.
|
|
83
|
+
#
|
|
84
|
+
# OpenCode project config is named opencode.json (no leading dot).
|
|
85
|
+
# This method walks up from the provided directory looking for it,
|
|
86
|
+
# similar to how git finds .git directories.
|
|
87
|
+
#
|
|
88
|
+
# @param dir [String] starting directory
|
|
89
|
+
# @return [String] path to opencode.json (existing or default)
|
|
90
|
+
def discover_project_config(dir)
|
|
91
|
+
current = File.expand_path(dir)
|
|
92
|
+
root = File.expand_path("/")
|
|
93
|
+
|
|
94
|
+
while current != root
|
|
95
|
+
path = File.join(current, "opencode.json")
|
|
96
|
+
return path if File.exist?(path)
|
|
97
|
+
|
|
98
|
+
current = File.dirname(current)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
File.join(dir, "opencode.json")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Build environment variable overrides.
|
|
105
|
+
#
|
|
106
|
+
# Checks all three OpenCode environment variables and creates
|
|
107
|
+
# EnvOverride objects for any that are set. OPENCODE_CONFIG_CONTENT
|
|
108
|
+
# is always active if set (it's inline content, not a file path).
|
|
109
|
+
#
|
|
110
|
+
# @param env [Hash] environment variables hash
|
|
111
|
+
# @return [Array<EnvOverride>] detected overrides
|
|
112
|
+
def build_env_overrides(env) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength -- handles three different env vars with full attribute construction
|
|
113
|
+
overrides = []
|
|
114
|
+
|
|
115
|
+
if (value = env["OPENCODE_CONFIG"])
|
|
116
|
+
overrides << EnvOverride.new(
|
|
117
|
+
agent: :opencode,
|
|
118
|
+
name: "OPENCODE_CONFIG",
|
|
119
|
+
value: value,
|
|
120
|
+
path: value,
|
|
121
|
+
exists: File.exist?(value),
|
|
122
|
+
active: File.exist?(value),
|
|
123
|
+
note: "Config file override"
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
if (value = env["OPENCODE_CONFIG_DIR"])
|
|
128
|
+
path = File.join(value, "opencode.json")
|
|
129
|
+
overrides << EnvOverride.new(
|
|
130
|
+
agent: :opencode,
|
|
131
|
+
name: "OPENCODE_CONFIG_DIR",
|
|
132
|
+
value: value,
|
|
133
|
+
path: path,
|
|
134
|
+
exists: File.exist?(path),
|
|
135
|
+
active: File.exist?(path),
|
|
136
|
+
note: "Config directory override"
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
if (value = env["OPENCODE_CONFIG_CONTENT"])
|
|
141
|
+
overrides << EnvOverride.new(
|
|
142
|
+
agent: :opencode,
|
|
143
|
+
name: "OPENCODE_CONFIG_CONTENT",
|
|
144
|
+
value: value[0, 50] + (value.length > 50 ? "..." : ""),
|
|
145
|
+
path: "(inline)",
|
|
146
|
+
exists: true,
|
|
147
|
+
active: true,
|
|
148
|
+
note: "Inline config content"
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
overrides
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Build a Location object for a config layer.
|
|
156
|
+
#
|
|
157
|
+
# @param scope [Symbol] the scope (:global, :project)
|
|
158
|
+
# @param source [String] where this config comes from
|
|
159
|
+
# @param path [String] the file path
|
|
160
|
+
# @return [Location] a Location object (not yet marked active)
|
|
161
|
+
def build_layer(scope, source, path)
|
|
162
|
+
Location.new(
|
|
163
|
+
agent: :opencode,
|
|
164
|
+
scope: scope,
|
|
165
|
+
source: source,
|
|
166
|
+
path: path,
|
|
167
|
+
exists: File.exist?(path),
|
|
168
|
+
active: false,
|
|
169
|
+
note: nil
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Mark the first existing layer as active.
|
|
174
|
+
#
|
|
175
|
+
# @param layers [Array<Location>] config layers
|
|
176
|
+
# @return [Array<Location>] layers with active flag set
|
|
177
|
+
def mark_active(layers)
|
|
178
|
+
found = layers.find(&:exists?)
|
|
179
|
+
layers.map do |layer|
|
|
180
|
+
layer.with(active: layer == found)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentSettings
|
|
4
|
+
# The complete result of resolving an agent's config location.
|
|
5
|
+
#
|
|
6
|
+
# AgentConfigPath is the primary return value from {AgentSettings.resolve}
|
|
7
|
+
# and contains all information about where an agent's configuration is
|
|
8
|
+
# located, including:
|
|
9
|
+
#
|
|
10
|
+
# - The effective (active) config location
|
|
11
|
+
# - Global and project-specific locations
|
|
12
|
+
# - All config layers organized by precedence
|
|
13
|
+
# - Environment variable overrides detected
|
|
14
|
+
# - Any warnings (e.g., untrusted project)
|
|
15
|
+
#
|
|
16
|
+
# The effective location is determined by checking in order:
|
|
17
|
+
# 1. Active environment variable overrides (highest precedence)
|
|
18
|
+
# 2. First existing layer in precedence order
|
|
19
|
+
# 3. First layer if none exist
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# result = AgentSettings.resolve(:claude, dir: Dir.pwd)
|
|
23
|
+
# result.effective.path #=> "/Users/me/.claude/settings.json"
|
|
24
|
+
# result.effective.exists? #=> true
|
|
25
|
+
# result.global #=> Location for global config
|
|
26
|
+
# result.project #=> Location for project config
|
|
27
|
+
# result.custom_config? #=> false (no env override active)
|
|
28
|
+
#
|
|
29
|
+
# @attr agent [Symbol] the agent this config path is for
|
|
30
|
+
# @attr effective [Location, nil] the currently active config location
|
|
31
|
+
# @attr global [Location, nil] the global (user) config location
|
|
32
|
+
# @attr project [Location, nil] the project-level config location
|
|
33
|
+
# @attr layers [Array<Location>] all config locations in precedence order
|
|
34
|
+
# @attr env_overrides [Array<EnvOverride>] detected environment variable overrides
|
|
35
|
+
# @attr warnings [Array<String>] any warnings about the configuration
|
|
36
|
+
AgentConfigPath = Data.define(:agent, :effective, :global, :project, :layers, :env_overrides, :warnings) do
|
|
37
|
+
# Check if a custom config is being used via environment variable.
|
|
38
|
+
#
|
|
39
|
+
# Returns true if any environment variable override is active,
|
|
40
|
+
# meaning the agent is using a config location different from
|
|
41
|
+
# the default file-based locations.
|
|
42
|
+
#
|
|
43
|
+
# @return [Boolean] true if an environment override is active
|
|
44
|
+
#
|
|
45
|
+
# @example
|
|
46
|
+
# env = { "OPENCODE_CONFIG" => "/custom/config.json" }
|
|
47
|
+
# result = AgentSettings.resolve(:opencode, dir: Dir.pwd, env: env)
|
|
48
|
+
# result.custom_config? #=> true (if /custom/config.json exists)
|
|
49
|
+
def custom_config? = env_overrides.any?(&:active?)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentSettings
|
|
4
|
+
# Represents an environment variable that overrides config location.
|
|
5
|
+
#
|
|
6
|
+
# Agents can use environment variables to override their default config
|
|
7
|
+
# locations. EnvOverride captures information about these variables,
|
|
8
|
+
# including their name, value, the resulting config path, and whether
|
|
9
|
+
# the override is active (i.e., the target file exists).
|
|
10
|
+
#
|
|
11
|
+
# Supported environment variables by agent:
|
|
12
|
+
# - Claude: CLAUDE_CONFIG_DIR
|
|
13
|
+
# - OpenCode: OPENCODE_CONFIG, OPENCODE_CONFIG_DIR, OPENCODE_CONFIG_CONTENT
|
|
14
|
+
# - Codex: CODEX_HOME
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# override = EnvOverride.new(
|
|
18
|
+
# agent: :claude,
|
|
19
|
+
# name: 'CLAUDE_CONFIG_DIR',
|
|
20
|
+
# value: '/custom/config',
|
|
21
|
+
# path: '/custom/config/settings.json',
|
|
22
|
+
# exists: true,
|
|
23
|
+
# active: true,
|
|
24
|
+
# note: 'Config directory override'
|
|
25
|
+
# )
|
|
26
|
+
# override.active? #=> true
|
|
27
|
+
#
|
|
28
|
+
# @attr agent [Symbol] the agent this override applies to
|
|
29
|
+
# @attr name [String] the environment variable name (e.g., 'CLAUDE_CONFIG_DIR')
|
|
30
|
+
# @attr value [String] the value of the environment variable
|
|
31
|
+
# @attr path [String] the resulting config file path
|
|
32
|
+
# @attr exists [Boolean] whether the config file exists at this path
|
|
33
|
+
# @attr active [Boolean] whether this override is in effect (file exists)
|
|
34
|
+
# @attr note [String] description of what this override does
|
|
35
|
+
EnvOverride = Data.define(:agent, :name, :value, :path, :exists, :active, :note) do
|
|
36
|
+
# @return [Boolean] whether the config file exists at this path
|
|
37
|
+
def exists? = exists
|
|
38
|
+
|
|
39
|
+
# @return [Boolean] whether this override is in effect
|
|
40
|
+
def active? = active
|
|
41
|
+
|
|
42
|
+
# Convert this override to a Location object.
|
|
43
|
+
#
|
|
44
|
+
# When an environment override is active, it becomes the effective
|
|
45
|
+
# config location. This method converts the override to a Location
|
|
46
|
+
# with scope :env.
|
|
47
|
+
#
|
|
48
|
+
# @return [Location] a Location representing this override
|
|
49
|
+
def to_location
|
|
50
|
+
Location.new(
|
|
51
|
+
agent: agent,
|
|
52
|
+
scope: :env,
|
|
53
|
+
source: name,
|
|
54
|
+
path: path,
|
|
55
|
+
exists: exists,
|
|
56
|
+
active: active,
|
|
57
|
+
note: note
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentSettings
|
|
4
|
+
# Represents a config file location for an agent.
|
|
5
|
+
#
|
|
6
|
+
# A Location describes where a configuration file is (or would be) stored,
|
|
7
|
+
# whether it exists, and whether it's the currently active configuration.
|
|
8
|
+
# Locations are organized by scope (global vs project) and source
|
|
9
|
+
# (user, system, file, etc.).
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# location = Location.new(
|
|
13
|
+
# agent: :claude,
|
|
14
|
+
# scope: :global,
|
|
15
|
+
# source: 'user',
|
|
16
|
+
# path: '/Users/me/.claude/settings.json',
|
|
17
|
+
# exists: true,
|
|
18
|
+
# active: true,
|
|
19
|
+
# note: nil
|
|
20
|
+
# )
|
|
21
|
+
# location.exists? #=> true
|
|
22
|
+
# location.active? #=> true
|
|
23
|
+
#
|
|
24
|
+
# @attr agent [Symbol] the agent this location belongs to (:claude, :opencode, :codex)
|
|
25
|
+
# @attr scope [Symbol] the scope of this location (:global, :project, :managed, :system, :env)
|
|
26
|
+
# @attr source [String] where this location comes from ('user', 'system', 'file', 'local')
|
|
27
|
+
# @attr path [String] the absolute file path to the config file
|
|
28
|
+
# @attr exists [Boolean] whether the config file exists at this path
|
|
29
|
+
# @attr active [Boolean] whether this is the currently effective config location
|
|
30
|
+
# @attr note [String, nil] optional note about this location
|
|
31
|
+
Location = Data.define(:agent, :scope, :source, :path, :exists, :active, :note) do
|
|
32
|
+
# @return [Boolean] whether the config file exists at this path
|
|
33
|
+
def exists? = exists
|
|
34
|
+
|
|
35
|
+
# @return [Boolean] whether this is the currently effective config location
|
|
36
|
+
def active? = active
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentSettings
|
|
4
|
+
# Internal module for discovering agent config locations.
|
|
5
|
+
#
|
|
6
|
+
# LocationDiscovery contains the core logic for finding and resolving
|
|
7
|
+
# where agent configuration files are located. It uses adapters (one per
|
|
8
|
+
# agent) to handle agent-specific rules, then applies common precedence
|
|
9
|
+
# logic to determine the effective configuration.
|
|
10
|
+
#
|
|
11
|
+
# This module is used internally by the {AgentSettings} public API methods.
|
|
12
|
+
# You typically don't need to call it directly.
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
module LocationDiscovery
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Find the global config location for an agent.
|
|
19
|
+
#
|
|
20
|
+
# Queries the appropriate adapter and extracts the global-scoped
|
|
21
|
+
# location from the layers.
|
|
22
|
+
#
|
|
23
|
+
# @param agent [Symbol] the agent identifier
|
|
24
|
+
# @param env [Hash] environment variables
|
|
25
|
+
# @param dir [String] project directory
|
|
26
|
+
# @param trusted [Boolean] whether project is trusted
|
|
27
|
+
# @return [Location, nil] the global location, or nil
|
|
28
|
+
def global(agent, env:, dir:, trusted:)
|
|
29
|
+
adapter = Registry.adapter(agent)
|
|
30
|
+
layers = adapter.layers(dir: dir, env: env, trusted: trusted)
|
|
31
|
+
layers.find { |l| l.scope == :global }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Find the project config location for an agent.
|
|
35
|
+
#
|
|
36
|
+
# Queries the appropriate adapter and extracts the project-scoped
|
|
37
|
+
# location from the layers. Note that some agents (like Codex) may
|
|
38
|
+
# not return a project location for untrusted projects.
|
|
39
|
+
#
|
|
40
|
+
# @param agent [Symbol] the agent identifier
|
|
41
|
+
# @param dir [String] project directory
|
|
42
|
+
# @param env [Hash] environment variables
|
|
43
|
+
# @param trusted [Boolean] whether project is trusted
|
|
44
|
+
# @return [Location, nil] the project location, or nil
|
|
45
|
+
def project(agent, dir:, env:, trusted:)
|
|
46
|
+
adapter = Registry.adapter(agent)
|
|
47
|
+
layers = adapter.layers(dir: dir, env: env, trusted: trusted)
|
|
48
|
+
layers.find { |l| l.scope == :project }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Resolve complete config path information for an agent.
|
|
52
|
+
#
|
|
53
|
+
# This is the main discovery method. It:
|
|
54
|
+
# 1. Gets the appropriate adapter for the agent
|
|
55
|
+
# 2. Retrieves all config layers from the adapter
|
|
56
|
+
# 3. Retrieves any environment variable overrides
|
|
57
|
+
# 4. Retrieves any warnings
|
|
58
|
+
# 5. Determines the effective location based on precedence
|
|
59
|
+
# 6. Builds and returns an AgentConfigPath with all information
|
|
60
|
+
#
|
|
61
|
+
# @param agent [Symbol] the agent identifier
|
|
62
|
+
# @param dir [String] project directory
|
|
63
|
+
# @param env [Hash] environment variables
|
|
64
|
+
# @param trusted [Boolean] whether project is trusted
|
|
65
|
+
# @return [AgentConfigPath] complete config path information
|
|
66
|
+
def resolve(agent, dir:, env:, trusted:) # rubocop:disable Metrics/MethodLength -- orchestrates adapter calls and builds complete AgentConfigPath
|
|
67
|
+
adapter = Registry.adapter(agent)
|
|
68
|
+
layers = adapter.layers(dir: dir, env: env, trusted: trusted)
|
|
69
|
+
env_overrides = adapter.env_overrides(dir: dir, env: env, trusted: trusted)
|
|
70
|
+
warnings = adapter.warnings(dir: dir, env: env, trusted: trusted)
|
|
71
|
+
|
|
72
|
+
effective = find_effective(layers, env_overrides)
|
|
73
|
+
global = layers.find { |l| l.scope == :global }
|
|
74
|
+
project = layers.find { |l| l.scope == :project }
|
|
75
|
+
|
|
76
|
+
AgentConfigPath.new(
|
|
77
|
+
agent: agent,
|
|
78
|
+
effective: effective,
|
|
79
|
+
global: global,
|
|
80
|
+
project: project,
|
|
81
|
+
layers: layers,
|
|
82
|
+
env_overrides: env_overrides,
|
|
83
|
+
warnings: warnings
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Resolve config paths for all supported agents.
|
|
88
|
+
#
|
|
89
|
+
# Iterates through all known agents and resolves each one,
|
|
90
|
+
# returning a hash mapping agent symbols to AgentConfigPath objects.
|
|
91
|
+
#
|
|
92
|
+
# @param dir [String] project directory
|
|
93
|
+
# @param env [Hash] environment variables
|
|
94
|
+
# @param trusted [Boolean] whether project is trusted
|
|
95
|
+
# @return [Hash{Symbol => AgentConfigPath}] map of agent to config path
|
|
96
|
+
def all(dir:, env:, trusted:)
|
|
97
|
+
Registry::AGENTS.to_h do |agent|
|
|
98
|
+
[agent, resolve(agent, dir: dir, env: env, trusted: trusted)]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Determine the effective config location.
|
|
103
|
+
#
|
|
104
|
+
# The effective location is determined by precedence:
|
|
105
|
+
# 1. If an environment override is active, convert it to a Location
|
|
106
|
+
# 2. Otherwise, use the first existing layer
|
|
107
|
+
# 3. If no layers exist, use the first layer (even though it doesn't exist)
|
|
108
|
+
#
|
|
109
|
+
# @param layers [Array<Location>] config layers in precedence order
|
|
110
|
+
# @param env_overrides [Array<EnvOverride>] environment variable overrides
|
|
111
|
+
# @return [Location, nil] the effective location
|
|
112
|
+
def find_effective(layers, env_overrides)
|
|
113
|
+
active_override = env_overrides.find(&:active?)
|
|
114
|
+
return active_override.to_location if active_override
|
|
115
|
+
|
|
116
|
+
layers.find(&:active?) || layers.first
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentSettings
|
|
4
|
+
# Registry mapping agent symbols to their adapter classes.
|
|
5
|
+
#
|
|
6
|
+
# The Registry is responsible for knowing which adapters exist and
|
|
7
|
+
# providing the correct adapter instance for a given agent symbol.
|
|
8
|
+
# It also defines the list of supported agents.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
module Registry
|
|
12
|
+
# List of supported agent symbols.
|
|
13
|
+
AGENTS = %i[claude opencode codex].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Get the adapter instance for an agent.
|
|
18
|
+
#
|
|
19
|
+
# Returns a new instance of the appropriate adapter class for the
|
|
20
|
+
# given agent symbol. Uses pattern matching to select the adapter.
|
|
21
|
+
#
|
|
22
|
+
# @param agent [Symbol] the agent identifier (:claude, :opencode, :codex)
|
|
23
|
+
# @return [Object] an instance of the appropriate adapter class
|
|
24
|
+
# @raise [UnknownAgentError] if the agent symbol is not recognized
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# adapter = Registry.adapter(:claude) #=> AgentSettings::Adapters::Claude instance
|
|
28
|
+
# adapter.layers(dir: Dir.pwd, env: {}, trusted: true)
|
|
29
|
+
def adapter(agent)
|
|
30
|
+
case agent
|
|
31
|
+
in :claude then Adapters::Claude.new
|
|
32
|
+
in :opencode then Adapters::Opencode.new
|
|
33
|
+
in :codex then Adapters::Codex.new
|
|
34
|
+
else
|
|
35
|
+
raise UnknownAgentError, "Unknown agent: #{agent}. Supported: #{AGENTS.join(", ")}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|