Class: KairosMcp::PluginProjector
- Inherits:
-
Object
- Object
- KairosMcp::PluginProjector
- 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
-
#data_dir ⇒ Object
readonly
Returns the value of attribute data_dir.
-
#host ⇒ Object
readonly
Returns the value of attribute host.
-
#mode ⇒ Object
readonly
Returns the value of attribute mode.
-
#output_root ⇒ Object
readonly
Returns the value of attribute output_root.
-
#project_root ⇒ Object
readonly
Returns the value of attribute project_root.
Instance Method Summary collapse
-
#context_region_present? ⇒ Boolean
True if the managed marker region currently exists in this host's context file.
-
#initialize(project_root, mode: :auto, data_dir: nil, host: :claude) ⇒ PluginProjector
constructor
Construct a PluginProjector.
-
#instruction_mode_status ⇒ Object
Status summary for the instruction mode projection.
-
#project!(enabled_skillsets, knowledge_entries: []) ⇒ Object
Main entry: project all SkillSet plugin artifacts + L1 knowledge meta skill.
-
#project_if_changed!(enabled_skillsets, knowledge_entries: []) ⇒ Object
Digest-based no-op: skip projection if nothing changed.
-
#project_instruction_mode!(mode_name, body, mode_version: nil) ⇒ Hash
Project the active instruction mode body.
-
#remove_projected_instruction_mode! ⇒ Hash
Remove the projected instruction mode artifact and CLAUDE.md region.
-
#status ⇒ Object
Status summary for MCP tool.
-
#verify ⇒ Object
Verify projected files match manifest.
Constructor Details
#initialize(project_root, mode: :auto, data_dir: nil, host: :claude) ⇒ PluginProjector
Construct a PluginProjector.
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_dir ⇒ Object (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 |
#host ⇒ Object (readonly)
Returns the value of attribute host.
101 102 103 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 101 def host @host end |
#mode ⇒ Object (readonly)
Returns the value of attribute mode.
101 102 103 |
# File 'lib/kairos_mcp/plugin_projector.rb', line 101 def mode @mode end |
#output_root ⇒ Object (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_root ⇒ Object (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.
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_status ⇒ Object
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 (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.
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.
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 |
#status ⇒ Object
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 |
#verify ⇒ Object
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 |