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

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(project_root, mode: :auto) ⇒ PluginProjector

Returns a new instance of PluginProjector.



32
33
34
35
36
37
38
# File 'lib/kairos_mcp/plugin_projector.rb', line 32

def initialize(project_root, mode: :auto)
  @project_root = project_root
  @mode = resolve_mode(mode)
  @output_root = @mode == :plugin ? project_root : File.join(project_root, '.claude')
  @manifest_path = File.join(project_root, '.kairos', 'projection_manifest.json')
  @instruction_mode_manifest_path = File.join(project_root, '.kairos', 'instruction_mode_manifest.json')
end

Instance Attribute Details

#modeObject (readonly)

Returns the value of attribute mode.



30
31
32
# File 'lib/kairos_mcp/plugin_projector.rb', line 30

def mode
  @mode
end

#output_rootObject (readonly)

Returns the value of attribute output_root.



30
31
32
# File 'lib/kairos_mcp/plugin_projector.rb', line 30

def output_root
  @output_root
end

#project_rootObject (readonly)

Returns the value of attribute project_root.



30
31
32
# File 'lib/kairos_mcp/plugin_projector.rb', line 30

def project_root
  @project_root
end

Instance Method Details

#instruction_mode_statusObject

Status summary for the instruction mode projection.



159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/kairos_mcp/plugin_projector.rb', line 159

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'],
    region_present: manifest['region_present'],
    projected_at: manifest['projected_at']
  }
end

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

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



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/kairos_mcp/plugin_projector.rb', line 41

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

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

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

  project_knowledge_meta_skill!(knowledge_entries, current_outputs)
  write_merged_hooks!(merged_hooks, 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



62
63
64
65
66
67
# File 'lib/kairos_mcp/plugin_projector.rb', line 62

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)


108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/kairos_mcp/plugin_projector.rb', line 108

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

  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)

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



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/kairos_mcp/plugin_projector.rb', line 139

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

#statusObject

Status summary for MCP tool



70
71
72
73
74
75
76
77
78
79
# File 'lib/kairos_mcp/plugin_projector.rb', line 70

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



82
83
84
85
86
87
88
# File 'lib/kairos_mcp/plugin_projector.rb', line 82

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