Class: KairosMcp::PluginProjector::HostProfile

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

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.

Parameters:

  • skill_projection (Symbol)

    :own (project skills into this host) or :reuse_claude (host reads .claude/skills/ directly — skip skill projection).

  • agents_subdir (String)

    subdir under output_root for agents ('agents' or 'agent').

  • aliases (Array<String>) (defaults to: [])

    alternate lookup keys (e.g. 'claude_code').

  • requires_host (String, nil) (defaults to: nil)

    key of a prerequisite host whose projected artifacts must exist on disk before this host can project (INV-H5).

  • hooks_writer (#call, nil) (defaults to: nil)

    callable(projector, merged_hooks, outputs) owning this host's hooks delivery. nil → hooks are not deliverable for this host and are skipped with a warning.

  • agent_converter (#call, nil) (defaults to: nil)

    callable(content) reshaping an agent definition into this host's expected frontmatter. nil → verbatim.

  • reload_hint (String, nil) (defaults to: nil)

    host-specific message appended after a successful projection (e.g. Claude's "/reload-plugins" note). Carried as profile data so the tool never branches on a host name (INV-H1).

  • mcp_config_writer (#call, nil) (defaults to: nil)

    callable(projector, outputs) that projects this host's MCP-server connection config (so the host can call kairos-chain tools). nil → the host manages MCP registration itself. Travels with the profile (INV-H4).

Raises:

  • (InvalidProfile)

    when output_subdir or context_file would escape the project root (INV-H11): the containment boundary is checked at registration time, not discovered at projection time.



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_converterObject (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_subdirObject (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

#aliasesObject (readonly)

Returns the value of attribute aliases.



50
51
52
# File 'lib/kairos_mcp/plugin_projector.rb', line 50

def aliases
  @aliases
end

#context_fileObject (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_writerObject (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_deliveryObject (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

#keyObject (readonly)

Returns the value of attribute key.



50
51
52
# File 'lib/kairos_mcp/plugin_projector.rb', line 50

def key
  @key
end

#manifest_suffixObject (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_writerObject (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_subdirObject (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_hintObject (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_hostObject (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_projectionObject (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

.availableObject

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_codeObject

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.

Returns:

  • (Boolean)


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.expand_path(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.

Raises:

  • (ArgumentError)


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.expand_path(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|
    meta = begin
      JSON.parse(File.read(json_path))
    rescue JSON::ParserError, Errno::ENOENT => e
      warn "[HostProfile] WARNING: skipping #{json_path} (unreadable skillset.json: #{e.message})"
      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(meta['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.expand_path(File.join(ss_root, rel.to_s))
      begin
        @active_load_source = load_source
        require path
      rescue ConflictError => e
        warn "[HostProfile] WARNING: #{e.message}"
      rescue StandardError, LoadError, SyntaxError => e
        warn "[HostProfile] WARNING: failed to load host profile #{path}: #{e.class}: #{e.message}"
      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

Returns:

  • (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