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



113
114
115
116
117
118
119
120
121
122
# File 'lib/kairos_mcp/plugin_projector.rb', line 113

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)
  @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.



101
102
103
# File 'lib/kairos_mcp/plugin_projector.rb', line 101

def data_dir
  @data_dir
end

#hostObject (readonly)

Returns the value of attribute host.



101
102
103
# File 'lib/kairos_mcp/plugin_projector.rb', line 101

def host
  @host
end

#modeObject (readonly)

Returns the value of attribute mode.



101
102
103
# File 'lib/kairos_mcp/plugin_projector.rb', line 101

def mode
  @mode
end

#output_rootObject (readonly)

Returns the value of attribute output_root.



101
102
103
# File 'lib/kairos_mcp/plugin_projector.rb', line 101

def output_root
  @output_root
end

#project_rootObject (readonly)

Returns the value of attribute project_root.



101
102
103
# File 'lib/kairos_mcp/plugin_projector.rb', line 101

def project_root
  @project_root
end

Instance Method Details

#context_region_present?Boolean

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

Returns:

  • (Boolean)


276
277
278
279
280
# File 'lib/kairos_mcp/plugin_projector.rb', line 276

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.



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/kairos_mcp/plugin_projector.rb', line 259

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

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

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



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/kairos_mcp/plugin_projector.rb', line 132

def project!(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)
  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



157
158
159
160
161
162
# File 'lib/kairos_mcp/plugin_projector.rb', line 157

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)


203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
# File 'lib/kairos_mcp/plugin_projector.rb', line 203

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



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/kairos_mcp/plugin_projector.rb', line 239

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



165
166
167
168
169
170
171
172
173
174
# File 'lib/kairos_mcp/plugin_projector.rb', line 165

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



177
178
179
180
181
182
183
# File 'lib/kairos_mcp/plugin_projector.rb', line 177

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