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:



292
293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/kairos_mcp/plugin_projector.rb', line 292

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.



280
281
282
# File 'lib/kairos_mcp/plugin_projector.rb', line 280

def data_dir
  @data_dir
end

#hostObject (readonly)

Returns the value of attribute host.



280
281
282
# File 'lib/kairos_mcp/plugin_projector.rb', line 280

def host
  @host
end

#modeObject (readonly)

Returns the value of attribute mode.



280
281
282
# File 'lib/kairos_mcp/plugin_projector.rb', line 280

def mode
  @mode
end

#output_rootObject (readonly)

Returns the value of attribute output_root.



280
281
282
# File 'lib/kairos_mcp/plugin_projector.rb', line 280

def output_root
  @output_root
end

#project_rootObject (readonly)

Returns the value of attribute project_root.



280
281
282
# File 'lib/kairos_mcp/plugin_projector.rb', line 280

def project_root
  @project_root
end

Instance Method Details

#atomic_write(path, content) ⇒ Object

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



939
940
941
942
943
944
945
946
947
948
949
# File 'lib/kairos_mcp/plugin_projector.rb', line 939

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)


464
465
466
467
468
# File 'lib/kairos_mcp/plugin_projector.rb', line 464

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.



447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
# File 'lib/kairos_mcp/plugin_projector.rb', line 447

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)



817
818
819
820
821
822
823
# File 'lib/kairos_mcp/plugin_projector.rb', line 817

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)


720
721
722
723
724
725
726
727
728
729
730
731
732
733
# File 'lib/kairos_mcp/plugin_projector.rb', line 720

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



318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
# File 'lib/kairos_mcp/plugin_projector.rb', line 318

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



345
346
347
348
349
350
# File 'lib/kairos_mcp/plugin_projector.rb', line 345

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)


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
416
417
418
419
420
421
422
# File 'lib/kairos_mcp/plugin_projector.rb', line 391

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: }



427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
# File 'lib/kairos_mcp/plugin_projector.rb', line 427

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.



751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
# File 'lib/kairos_mcp/plugin_projector.rb', line 751

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



353
354
355
356
357
358
359
360
361
362
# File 'lib/kairos_mcp/plugin_projector.rb', line 353

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



365
366
367
368
369
370
371
# File 'lib/kairos_mcp/plugin_projector.rb', line 365

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



781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
# File 'lib/kairos_mcp/plugin_projector.rb', line 781

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