Class: KairosMcp::PluginProjector::HostProfile
- Inherits:
-
Object
- Object
- KairosMcp::PluginProjector::HostProfile
- Defined in:
- lib/kairos_mcp/plugin_projector.rb
Overview
Host-specific projection profile (2026-07-02 Codex support; 2026-07-10 add-on registration redesign, design v0.3 FROZEN: docs/drafts/multi_host_projection_addon_design_v0.3_FROZEN.md).
The core knows exactly one unit of host-specific knowledge — the profile
(INV-H1). All host-varying behavior is carried BY the profile as data or
callables (INV-H4): hooks_writer and agent_converter travel with the
profile; the engine dispatches only through this abstraction and never
branches on a host identity.
Profiles enter through a registry (INV-H3/H13). The built-in Claude Code
default registers through the same mechanism as every add-on and merely
registers first (INV-H2). Additional hosts (codex, opencode, ...) are
contributed by add-on SkillSets that declare host_profiles files in
their skillset.json; load_addons! discovers them uniformly for every
access path that constructs a projector.
Defined Under Namespace
Classes: ConflictError, InvalidProfile
Constant Summary collapse
- SAFE_OUTPUT_SUBDIR =
A safe output subdir is a single relative path segment with no traversal (e.g. '.claude', '.codex'). A safe context file is a bare filename.
/\A\.?[a-zA-Z0-9][a-zA-Z0-9._-]*\z/- SAFE_CONTEXT_FILE =
/\A[a-zA-Z0-9][a-zA-Z0-9._-]*\z/
Instance Attribute Summary collapse
-
#agent_converter ⇒ Object
readonly
Returns the value of attribute agent_converter.
-
#agents_subdir ⇒ Object
readonly
Returns the value of attribute agents_subdir.
-
#aliases ⇒ Object
readonly
Returns the value of attribute aliases.
-
#context_file ⇒ Object
readonly
Returns the value of attribute context_file.
-
#hooks_writer ⇒ Object
readonly
Returns the value of attribute hooks_writer.
-
#instruction_mode_delivery ⇒ Object
readonly
Returns the value of attribute instruction_mode_delivery.
-
#key ⇒ Object
readonly
Returns the value of attribute key.
-
#manifest_suffix ⇒ Object
readonly
Returns the value of attribute manifest_suffix.
-
#mcp_config_writer ⇒ Object
readonly
Returns the value of attribute mcp_config_writer.
-
#output_subdir ⇒ Object
readonly
Returns the value of attribute output_subdir.
-
#reload_hint ⇒ Object
readonly
Returns the value of attribute reload_hint.
-
#requires_host ⇒ Object
readonly
Returns the value of attribute requires_host.
-
#skill_projection ⇒ Object
readonly
Returns the value of attribute skill_projection.
Class Method Summary collapse
-
.available ⇒ Object
Registered host keys, built-in default first (registration order).
-
.claude_code ⇒ Object
Claude Code: .claude/ layout, CLAUDE.md with @-import pointer, hooks in settings.json.
-
.contained_realpath?(root, path) ⇒ Boolean
True if
pathresolves (following symlinks) to a location at or under the real path ofroot. -
.for(host) ⇒ Object
Resolve or fail with an actionable message listing registered hosts.
-
.load_addons!(data_dir) ⇒ Object
Discover add-on-contributed profiles under <data_dir>/skillsets/.
-
.lookup(host) ⇒ Object
Resolve a key or alias to a registered profile; nil when absent.
-
.register(profile, source:) ⇒ Object
Register a profile.
- .registered?(key) ⇒ Boolean
-
.reset_addon_discovery! ⇒ Object
Test seam: forget add-on discovery memoization (profiles stay registered).
-
.reset_registry! ⇒ Object
Test seam: clear the registry and re-register only the bundled default.
Instance Method Summary collapse
-
#initialize(key:, output_subdir:, context_file:, instruction_mode_delivery:, manifest_suffix:, skill_projection:, agents_subdir:, aliases: [], requires_host: nil, hooks_writer: nil, agent_converter: nil, reload_hint: nil, mcp_config_writer: nil) ⇒ HostProfile
constructor
A new instance of HostProfile.
-
#manifest_filename(base) ⇒ Object
Legacy manifest names carry no suffix so existing .kairos/ manifests keep working.
Constructor Details
#initialize(key:, output_subdir:, context_file:, instruction_mode_delivery:, manifest_suffix:, skill_projection:, agents_subdir:, aliases: [], requires_host: nil, hooks_writer: nil, agent_converter: nil, reload_hint: nil, mcp_config_writer: nil) ⇒ HostProfile
Returns a new instance of HostProfile.
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 88 def initialize(key:, output_subdir:, context_file:, instruction_mode_delivery:, manifest_suffix:, skill_projection:, agents_subdir:, aliases: [], requires_host: nil, hooks_writer: nil, agent_converter: nil, reload_hint: nil, mcp_config_writer: nil) unless output_subdir.is_a?(String) && output_subdir.match?(SAFE_OUTPUT_SUBDIR) raise InvalidProfile, "host #{key.inspect}: output_subdir #{output_subdir.inspect} is not a single safe relative segment (INV-H11)" end unless context_file.is_a?(String) && context_file.match?(SAFE_CONTEXT_FILE) raise InvalidProfile, "host #{key.inspect}: context_file #{context_file.inspect} is not a safe bare filename (INV-H11)" end @key = key @output_subdir = output_subdir @context_file = context_file @instruction_mode_delivery = instruction_mode_delivery @manifest_suffix = manifest_suffix @skill_projection = skill_projection @agents_subdir = agents_subdir @aliases = aliases @requires_host = requires_host @hooks_writer = hooks_writer @agent_converter = agent_converter @reload_hint = reload_hint @mcp_config_writer = mcp_config_writer end |
Instance Attribute Details
#agent_converter ⇒ Object (readonly)
Returns the value of attribute agent_converter.
50 51 52 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 50 def agent_converter @agent_converter end |
#agents_subdir ⇒ Object (readonly)
Returns the value of attribute agents_subdir.
50 51 52 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 50 def agents_subdir @agents_subdir end |
#aliases ⇒ Object (readonly)
Returns the value of attribute aliases.
50 51 52 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 50 def aliases @aliases end |
#context_file ⇒ Object (readonly)
Returns the value of attribute context_file.
50 51 52 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 50 def context_file @context_file end |
#hooks_writer ⇒ Object (readonly)
Returns the value of attribute hooks_writer.
50 51 52 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 50 def hooks_writer @hooks_writer end |
#instruction_mode_delivery ⇒ Object (readonly)
Returns the value of attribute instruction_mode_delivery.
50 51 52 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 50 def instruction_mode_delivery @instruction_mode_delivery end |
#key ⇒ Object (readonly)
Returns the value of attribute key.
50 51 52 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 50 def key @key end |
#manifest_suffix ⇒ Object (readonly)
Returns the value of attribute manifest_suffix.
50 51 52 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 50 def manifest_suffix @manifest_suffix end |
#mcp_config_writer ⇒ Object (readonly)
Returns the value of attribute mcp_config_writer.
50 51 52 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 50 def mcp_config_writer @mcp_config_writer end |
#output_subdir ⇒ Object (readonly)
Returns the value of attribute output_subdir.
50 51 52 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 50 def output_subdir @output_subdir end |
#reload_hint ⇒ Object (readonly)
Returns the value of attribute reload_hint.
50 51 52 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 50 def reload_hint @reload_hint end |
#requires_host ⇒ Object (readonly)
Returns the value of attribute requires_host.
50 51 52 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 50 def requires_host @requires_host end |
#skill_projection ⇒ Object (readonly)
Returns the value of attribute skill_projection.
50 51 52 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 50 def skill_projection @skill_projection end |
Class Method Details
.available ⇒ Object
Registered host keys, built-in default first (registration order).
145 146 147 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 145 def available registry.keys end |
.claude_code ⇒ Object
Claude Code: .claude/ layout, CLAUDE.md with @-import pointer, hooks in settings.json. This is the bundled default — it registers through the same mechanism as every add-on, and merely registers first (INV-H2).
259 260 261 262 263 264 265 266 267 268 269 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 259 def self.claude_code lookup('claude') || register( new(key: 'claude', output_subdir: '.claude', context_file: 'CLAUDE.md', instruction_mode_delivery: :import, manifest_suffix: nil, skill_projection: :own, agents_subdir: 'agents', aliases: %w[claude_code claude-code], reload_hint: ' Run /reload-plugins to activate.', hooks_writer: ->(projector, merged_hooks, outputs) { projector.write_hooks_to_settings!(merged_hooks, outputs) }), source: 'core:bundled_default' ) end |
.contained_realpath?(root, path) ⇒ Boolean
True if path resolves (following symlinks) to a location at or under
the real path of root. Rejects symlink escapes that a lexical prefix
check would miss (INV-H11). Non-existent paths fall back to lexical
containment so an entry naming a not-yet-created file is still bounded.
218 219 220 221 222 223 224 225 226 227 228 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 218 def contained_realpath?(root, path) root_real = File.realpath(root) begin target_real = File.realpath(path) rescue Errno::ENOENT target_real = File.(path) end target_real == root_real || target_real.start_with?(root_real + File::SEPARATOR) rescue Errno::ENOENT false end |
.for(host) ⇒ Object
Resolve or fail with an actionable message listing registered hosts.
162 163 164 165 166 167 168 169 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 162 def for(host) profile = lookup(host) return profile if profile raise ArgumentError, "unknown projection host: #{host.inspect} (registered: #{available.join(', ')}). " \ 'Additional hosts are provided by add-on SkillSets — install the SkillSet ' \ 'for this host into .kairos/skillsets/ and retry.' end |
.load_addons!(data_dir) ⇒ Object
Discover add-on-contributed profiles under <data_dir>/skillsets/.
Any skillset.json may declare "host_profiles": ["lib/....rb"]; each
file is loaded once and registers its profile(s) through register.
Malformed or escaping declarations are rejected here — at registration
time, not at projection time (INV-H11). Idempotent per data_dir.
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 176 def load_addons!(data_dir) return if data_dir.nil? dir_key = File.(data_dir.to_s) @loaded_addon_dirs ||= {} return if @loaded_addon_dirs[dir_key] @loaded_addon_dirs[dir_key] = true Dir.glob(File.join(dir_key, 'skillsets', '*', 'skillset.json')).sort.each do |json_path| = begin JSON.parse(File.read(json_path)) rescue JSON::ParserError, Errno::ENOENT => e warn "[HostProfile] WARNING: skipping #{json_path} (unreadable skillset.json: #{e.})" next end ss_root = File.dirname(json_path) # Source is derived from the on-disk SkillSet directory, not from the # profile's self-declared string, so provenance is trustworthy (INV-H13). load_source = "skillset:#{File.basename(ss_root)}" Array(['host_profiles']).each do |rel| unless contained_realpath?(ss_root, File.join(ss_root, rel.to_s)) warn "[HostProfile] WARNING: rejecting host_profiles entry #{rel.inspect} in #{json_path} (escapes the SkillSet directory)" next end path = File.(File.join(ss_root, rel.to_s)) begin @active_load_source = load_source require path rescue ConflictError => e warn "[HostProfile] WARNING: #{e.}" rescue StandardError, LoadError, SyntaxError => e warn "[HostProfile] WARNING: failed to load host profile #{path}: #{e.class}: #{e.}" ensure @active_load_source = nil end end end end |
.lookup(host) ⇒ Object
Resolve a key or alias to a registered profile; nil when absent.
150 151 152 153 154 155 156 157 158 159 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 150 def lookup(host) return host if host.is_a?(HostProfile) k = host.to_s entry = registry[k] return entry[:profile] if entry registry.each_value do |e| return e[:profile] if e[:profile].aliases.include?(k) end nil end |
.register(profile, source:) ⇒ Object
Register a profile. A second registration of an already-owned key from a different source is rejected deterministically (INV-H13). The source of an add-on registration is NOT the profile's self-declared string but the on-disk SkillSet identity established by load_addons! (via @active_load_source), so a squatter reusing a canonical source string cannot silently capture a host key.
128 129 130 131 132 133 134 135 136 137 138 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 128 def register(profile, source:) effective_source = @active_load_source || source registry_entry = registry[profile.key] if registry_entry && registry_entry[:source] != effective_source raise ConflictError, "host profile #{profile.key.inspect} already registered by #{registry_entry[:source].inspect}; " \ "rejecting conflicting registration from #{effective_source.inspect}" end registry[profile.key] = { profile: profile, source: effective_source } profile end |
.registered?(key) ⇒ Boolean
140 141 142 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 140 def registered?(key) registry.key?(key.to_s) end |
.reset_addon_discovery! ⇒ Object
Test seam: forget add-on discovery memoization (profiles stay registered).
231 232 233 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 231 def reset_addon_discovery! @loaded_addon_dirs = {} end |
.reset_registry! ⇒ Object
Test seam: clear the registry and re-register only the bundled default. NOTE: the registry is process-global, not scoped per data_dir. This is correct for the single-data_dir deployments (MCP server, CLI): every access path sees one registry (INV-H3). Under a shared-process multi-consumer transport (HTTP/multiuser) an add-on discovered for one data_dir would remain visible to another; per-tenant registry scoping is deferred to the multiuser track and is a precondition for enabling host add-ons there.
243 244 245 246 247 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 243 def reset_registry! @registry = {} @loaded_addon_dirs = {} HostProfile.claude_code end |
Instance Method Details
#manifest_filename(base) ⇒ Object
Legacy manifest names carry no suffix so existing .kairos/ manifests keep working.
115 116 117 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 115 def manifest_filename(base) @manifest_suffix ? "#{base}.#{@manifest_suffix}.json" : "#{base}.json" end |