Class: KairosMcp::PluginProjector

Inherits:
Object
  • Object
show all
Defined in:
lib/kairos_mcp/plugin_projector.rb

Overview

Projects SkillSet plugin artifacts to Claude Code plugin/project structure.

Dual-mode:

:project (default) — writes to .claude/skills/, .claude/agents/, .claude/settings.json
:plugin            — writes to plugin root skills/, agents/, hooks/hooks.json

Design: log/skillset_plugin_projection_design_v2.2_20260404.md

Defined Under Namespace

Classes: CoincidenceRefused, DependencyUnsatisfied, HostProfile, InstructionModeTooLarge

Constant Summary collapse

SEED_SKILLS =
%w[kairos-chain].freeze
PROJECTED_BY =
'kairos-chain'
SAFE_NAME_PATTERN =
/\A[a-zA-Z0-9][a-zA-Z0-9_-]*\z/
ALLOWED_HOOK_COMMANDS =
/\Akairos-/
INSTRUCTION_MODE_MARKER_BEGIN =
'<!-- BEGIN kairos-chain:instruction-mode _projected_by=kairos-chain -->'
INSTRUCTION_MODE_MARKER_END =
'<!-- END kairos-chain:instruction-mode -->'
INSTRUCTION_MODE_REL_PATH =
'kairos/instruction_mode.md'
INSTRUCTION_MODE_SIZE_WARN =
150 * 1024
INSTRUCTION_MODE_SIZE_REFUSE =
256 * 1024
INSTRUCTION_MODE_INLINE_WARN =

Inline delivery (AGENTS.md) can be truncated by a host's context-file byte cap (e.g. Codex project-doc limit), so warn earlier than the artifact thresholds above.

32 * 1024

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(project_root, mode: :auto, data_dir: nil, host: :claude) ⇒ PluginProjector

Construct a PluginProjector.

Parameters:

  • project_root (String)

    consumer project root (where .claude/ and CLAUDE.md live)

  • mode (Symbol) (defaults to: :auto)

    :auto, :project, or :plugin

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

    KairosChain data directory. When provided, the projector enforces design v0.2 Inv 3: refuses construction when real_path(project_root) == real_path(data_dir). When nil, the legacy assumption data_dir = project_root/.kairos is used for manifest location (backward-compat).

Raises:



285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/kairos_mcp/plugin_projector.rb', line 285

def initialize(project_root, mode: :auto, data_dir: nil, host: :claude)
  @project_root = project_root
  @data_dir = data_dir || File.join(project_root, '.kairos')
  enforce_no_coincidence!
  @mode = resolve_mode(mode)
  # Discover add-on host profiles at the single construction choke point so
  # every access path (server, CLI, hooks) sees the same registry (INV-H3).
  HostProfile.load_addons!(@data_dir)
  @host = HostProfile.for(host)
  @output_root = @mode == :plugin ? project_root : File.join(project_root, @host.output_subdir)
  @manifest_path = File.join(@data_dir, @host.manifest_filename('projection_manifest'))
  @instruction_mode_manifest_path = File.join(@data_dir, @host.manifest_filename('instruction_mode_manifest'))
end

Instance Attribute Details

#data_dirObject (readonly)

Returns the value of attribute data_dir.



273
274
275
# File 'lib/kairos_mcp/plugin_projector.rb', line 273

def data_dir
  @data_dir
end

#hostObject (readonly)

Returns the value of attribute host.



273
274
275
# File 'lib/kairos_mcp/plugin_projector.rb', line 273

def host
  @host
end

#modeObject (readonly)

Returns the value of attribute mode.



273
274
275
# File 'lib/kairos_mcp/plugin_projector.rb', line 273

def mode
  @mode
end

#output_rootObject (readonly)

Returns the value of attribute output_root.



273
274
275
# File 'lib/kairos_mcp/plugin_projector.rb', line 273

def output_root
  @output_root
end

#project_rootObject (readonly)

Returns the value of attribute project_root.



273
274
275
# File 'lib/kairos_mcp/plugin_projector.rb', line 273

def project_root
  @project_root
end

Instance Method Details

#atomic_write(path, content) ⇒ Object

Atomic write: tmpfile + rename to prevent partial reads (P1-1)



932
933
934
935
936
937
938
939
940
941
942
# File 'lib/kairos_mcp/plugin_projector.rb', line 932

def atomic_write(path, content)
  FileUtils.mkdir_p(File.dirname(path))
  tmp = Tempfile.new([File.basename(path), File.extname(path)], File.dirname(path))
  tmp.write(content)
  tmp.close
  File.rename(tmp.path, path)
rescue => e
  tmp&.close
  tmp&.unlink
  raise e
end

#context_region_present?Boolean

True if the managed marker region currently exists in this host's context file.

Returns:

  • (Boolean)


457
458
459
460
461
# File 'lib/kairos_mcp/plugin_projector.rb', line 457

def context_region_present?
  path = claudemd_path
  return false unless File.exist?(path)
  File.read(path).include?(INSTRUCTION_MODE_MARKER_BEGIN)
end

#instruction_mode_statusObject

Status summary for the instruction mode projection.



440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
# File 'lib/kairos_mcp/plugin_projector.rb', line 440

def instruction_mode_status
  manifest = load_instruction_mode_manifest
  {
    mode: @mode,
    active: !manifest.empty?,
    mode_name: manifest['mode_name'],
    mode_version: manifest['mode_version'],
    artifact_path: manifest['artifact_path'],
    artifact_size: manifest['artifact_size'],
    # Verify against the actual context file, not just the manifest: another host
    # sharing AGENTS.md may have stripped the region since this host projected.
    region_present: context_region_present?,
    projected_at: manifest['projected_at']
  }
end

#load_settings(path) ⇒ Object

Load settings.json with error handling (P1-2)



810
811
812
813
814
815
816
# File 'lib/kairos_mcp/plugin_projector.rb', line 810

def load_settings(path)
  return {} unless File.exist?(path)
  JSON.parse(File.read(path))
rescue JSON::ParserError => e
  warn "[PluginProjector] ERROR: #{path} has invalid JSON (#{e.message}). Skipping hooks merge."
  nil
end

#merge_project_root_json!(filename, top_key, entry_key, entry_value, outputs, output_type) ⇒ Object

Merge a single key into a JSON config file at the project root, preserving all other content, and record it in outputs. filename must be a bare filename (no separators) — the write is confined to <project_root>/ (INV-H11). Used by a profile's mcp_config_writer.

Raises:

  • (ArgumentError)


713
714
715
716
717
718
719
720
721
722
723
724
725
726
# File 'lib/kairos_mcp/plugin_projector.rb', line 713

def merge_project_root_json!(filename, top_key, entry_key, entry_value, outputs, output_type)
  raise ArgumentError, "unsafe config filename: #{filename.inspect}" unless filename.match?(HostProfile::SAFE_CONTEXT_FILE)
  path = File.join(@project_root, filename)
  config = load_settings(path) # {} when absent, nil on parse error
  return if config.nil?
  config[top_key] ||= {}
  unless config[top_key].is_a?(Hash)
    warn "[PluginProjector] WARNING: #{filename} '#{top_key}' is not an object; leaving it untouched."
    return
  end
  config[top_key][entry_key] = entry_value
  atomic_write(path, JSON.pretty_generate(config))
  outputs[path] = { 'type' => output_type }
end

#project!(enabled_skillsets, knowledge_entries: []) ⇒ Object

Main entry: project all SkillSet plugin artifacts + L1 knowledge meta skill



311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/kairos_mcp/plugin_projector.rb', line 311

def project!(enabled_skillsets, knowledge_entries: [])
  enforce_host_dependency!(enabled_skillsets, knowledge_entries)
  previous_manifest = load_manifest
  current_outputs = {}
  merged_hooks = @mode == :plugin ? load_seed_hooks : { 'hooks' => {} }

  # OpenCode reads .claude/skills/ directly (Claude co-use assumption), so it reuses the
  # Claude skill projection instead of duplicating skills into .opencode/skills/.
  reuse_skills = @host.skill_projection == :reuse_claude

  enabled_skillsets.each do |ss|
    next unless ss.has_plugin?

    plugin_dir = File.join(ss.path, 'plugin')
    project_skill!(ss, plugin_dir, current_outputs) unless reuse_skills
    project_agents!(ss, plugin_dir, current_outputs)
    collect_hooks!(ss, plugin_dir, merged_hooks)
  end

  project_knowledge_meta_skill!(knowledge_entries, current_outputs) unless reuse_skills
  write_merged_hooks!(merged_hooks, current_outputs)
  write_host_mcp_config!(current_outputs)
  cleanup_stale!(previous_manifest, current_outputs)
  save_manifest(current_outputs, enabled_skillsets, knowledge_entries)
end

#project_if_changed!(enabled_skillsets, knowledge_entries: []) ⇒ Object

Digest-based no-op: skip projection if nothing changed



338
339
340
341
342
343
# File 'lib/kairos_mcp/plugin_projector.rb', line 338

def project_if_changed!(enabled_skillsets, knowledge_entries: [])
  digest = compute_source_digest(enabled_skillsets, knowledge_entries)
  return false if digest == load_manifest.dig('source_digest')
  project!(enabled_skillsets, knowledge_entries: knowledge_entries)
  true
end

#project_instruction_mode!(mode_name, body, mode_version: nil) ⇒ Hash

Project the active instruction mode body.

Parameters:

  • mode_name (String)

    active mode name (e.g., 'masa', 'tutorial')

  • body (String)

    flat mode body (no @-imports inside)

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

    optional version label for the marker header

Returns:

  • (Hash)

    result summary { artifact_path:, region_written:, size_bytes: }

Raises:

  • (ArgumentError)


384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
# File 'lib/kairos_mcp/plugin_projector.rb', line 384

def project_instruction_mode!(mode_name, body, mode_version: nil)
  raise ArgumentError, "unsafe mode name: #{mode_name.inspect}" unless safe_name?(mode_name)

  size = body.bytesize
  raise InstructionModeTooLarge.new(size, INSTRUCTION_MODE_SIZE_REFUSE) if size > INSTRUCTION_MODE_SIZE_REFUSE
  warn "[PluginProjector] WARNING: instruction mode body is #{size} bytes (warn threshold #{INSTRUCTION_MODE_SIZE_WARN})" if size > INSTRUCTION_MODE_SIZE_WARN
  if @host.instruction_mode_delivery == :inline && size > INSTRUCTION_MODE_INLINE_WARN
    warn "[PluginProjector] WARNING: inlining #{size} bytes into #{@host.context_file}; " \
         "host '#{@host.key}' may cap the context-file read (e.g. Codex project-doc byte limit). " \
         "Raise the host's context-file byte cap if the projected mode body appears truncated."
  end

  artifact_path = File.join(@output_root, INSTRUCTION_MODE_REL_PATH)
  raise "instruction mode artifact path outside output_root: #{artifact_path}" unless safe_path?(artifact_path)

  FileUtils.mkdir_p(File.dirname(artifact_path))
  atomic_write(artifact_path, body)

  region_written = merge_instruction_mode_region!(mode_name, mode_version, artifact_path, body)

  save_instruction_mode_manifest(
    'mode_name' => mode_name,
    'mode_version' => mode_version,
    'artifact_path' => artifact_path,
    'artifact_size' => size,
    'artifact_digest' => Digest::SHA256.hexdigest(body),
    'region_present' => region_written,
    'projected_at' => Time.now.utc.iso8601
  )

  { artifact_path: artifact_path, region_written: region_written, size_bytes: size }
end

#remove_projected_instruction_mode!Hash

Remove the projected instruction mode artifact and CLAUDE.md region.

Returns:

  • (Hash)

    result summary { artifact_removed:, region_removed: }



420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
# File 'lib/kairos_mcp/plugin_projector.rb', line 420

def remove_projected_instruction_mode!
  manifest = load_instruction_mode_manifest
  artifact_path = manifest['artifact_path'] || File.join(@output_root, INSTRUCTION_MODE_REL_PATH)

  artifact_removed = false
  if File.exist?(artifact_path) && safe_path?(artifact_path)
    FileUtils.rm_f(artifact_path)
    parent = File.dirname(artifact_path)
    FileUtils.rmdir(parent) if Dir.exist?(parent) && Dir.empty?(parent)
    artifact_removed = true
  end

  region_removed = remove_instruction_mode_region!

  save_instruction_mode_manifest(nil) # clear

  { artifact_removed: artifact_removed, region_removed: region_removed }
end

#rewrite_hook_commands_for_host(merged_hooks) ⇒ Object

Ensure projected re-projection hooks target THIS host, not the default (claude). Deep-copies so the shared merged_hooks (used by other hosts) is not mutated.



744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
# File 'lib/kairos_mcp/plugin_projector.rb', line 744

def rewrite_hook_commands_for_host(merged_hooks)
  copy = JSON.parse(JSON.generate(merged_hooks))
  copy.fetch('hooks', {}).each_value do |handlers|
    next unless handlers.is_a?(Array)
    handlers.each do |h|
      Array(h['hooks']).each do |inner|
        cmd = inner['command']
        next unless cmd.is_a?(String) && cmd.include?('kairos-plugin-project') && !cmd.include?('--host')
        # Insert right after the binary token so compound commands (a && b) stay correct.
        inner['command'] = cmd.sub('kairos-plugin-project', "kairos-plugin-project --host #{@host.key}")
      end
    end
  end
  copy
end

#statusObject

Status summary for MCP tool



346
347
348
349
350
351
352
353
354
355
# File 'lib/kairos_mcp/plugin_projector.rb', line 346

def status
  manifest = load_manifest
  {
    mode: @mode,
    output_root: @output_root,
    projected_at: manifest['projected_at'],
    source_digest: manifest['source_digest'],
    output_count: manifest.fetch('outputs', {}).size
  }
end

#verifyObject

Verify projected files match manifest



358
359
360
361
362
363
364
# File 'lib/kairos_mcp/plugin_projector.rb', line 358

def verify
  manifest = load_manifest
  outputs = manifest.fetch('outputs', {})
  missing = outputs.keys.reject { |f| File.exist?(f) }
  orphaned = find_orphaned_files(outputs)
  { valid: missing.empty? && orphaned.empty?, missing: missing, orphaned: orphaned }
end

#write_hooks_to_settings!(merged_hooks, outputs) ⇒ Object

Project mode: merge hooks into .claude/settings.json



774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
# File 'lib/kairos_mcp/plugin_projector.rb', line 774

def write_hooks_to_settings!(merged_hooks, outputs)
  settings_path = File.join(@output_root, 'settings.json')
  settings = load_settings(settings_path)
  return if settings.nil? # JSON parse failed, abort

  remove_projected_hooks!(settings)

  unless merged_hooks['hooks'].empty?
    settings['hooks'] ||= {}
    merged_hooks['hooks'].each do |event, handlers|
      settings['hooks'][event] ||= []
      tagged = handlers.map { |h| h.merge('_projected_by' => PROJECTED_BY) }
      settings['hooks'][event].concat(tagged)
    end
  end

  # Clean up empty hooks
  settings['hooks']&.delete_if { |_, v| v.is_a?(Array) && v.empty? }
  settings.delete('hooks') if settings['hooks']&.empty?

  atomic_write(settings_path, JSON.pretty_generate(settings))
  outputs[settings_path] = { 'type' => 'hooks_settings_merge' }
end