# frozen_string_literal: true require_relative 'base' module PluginConfig module Operations # Structural validation for a plugin directory: plugin.json shape, # marketplace registration/consistency, and skills/ layout. class Validate < Base def initialize(params) super @plugin_path = params[:plugin_path] end attr_reader :plugin_path def call return missing_plugin_path_result unless plugin_path && !plugin_path.to_s.strip.empty? load_state checks = { plugin_json: check_plugin_json, required_fields: check_required_fields, name_matches_directory: check_name_matches_directory, marketplace_entry: check_marketplace_entry, description_matches: check_description_matches, skill_md_present: check_skill_md_present, skills_array_matches: check_skills_array_matches, marketplace_source: check_marketplace_source } build_result(checks) end private # ─── State loading ────────────────────────────────────────────────────── def load_state @plugin_json = load_plugin_json @marketplace_data = read_json(marketplace_file_path) @marketplace_entry = find_marketplace_entry end # True when no --marketplace_file was given and the default marketplace # location can't be found. In this case marketplace-dependent checks are # skipped rather than failed, so the validator works against plugin # directories that aren't registered in any marketplace.json (e.g. when # invoked from a different repo). If --marketplace_file was explicitly # given, a missing/invalid file is still a hard failure. def marketplace_explicitly_given? !marketplace_file.to_s.strip.empty? end def skip_marketplace_checks? !marketplace_explicitly_given? && !File.exist?(default_marketplace_file) end def plugin_json_path File.join(plugin_path, '.claude-plugin', 'plugin.json') end def load_plugin_json return nil unless File.exist?(plugin_json_path) read_json(plugin_json_path) end def marketplace_file_path marketplace_file || default_marketplace_file end def default_marketplace_file File.join(File.dirname(File.expand_path(plugin_path)), '.claude-plugin', 'marketplace.json') end def find_marketplace_entry return nil unless @plugin_json && @marketplace_data (@marketplace_data['plugins'] || []).find { |p| p['name'] == @plugin_json['name'] } end def skills_root File.join(plugin_path, 'skills') end def skill_directories return [] unless Dir.exist?(skills_root) Dir.children(skills_root).select { |entry| File.directory?(File.join(skills_root, entry)) }.sort end # ─── Checks ───────────────────────────────────────────────────────────── def check_plugin_json path = plugin_json_path return fail_check("plugin.json not found at #{path}") unless File.exist?(path) return fail_check("plugin.json at #{path} contains invalid JSON") unless @plugin_json pass_check("plugin.json exists and is valid JSON at #{path}") end def check_required_fields return fail_check('Cannot check required fields: plugin.json is missing or invalid') unless @plugin_json missing = %w[name description].select { |key| blank?(@plugin_json[key]) } return fail_check("Missing or empty required field(s): #{missing.join(', ')}") unless missing.empty? pass_check('Required fields present: name, description') end def check_name_matches_directory return fail_check('Cannot check name: plugin.json is missing or invalid') unless @plugin_json expected = directory_basename actual = @plugin_json['name'] return fail_check("plugin.json name #{actual.inspect} does not match directory basename #{expected.inspect}") unless actual == expected pass_check("plugin.json name '#{actual}' matches directory basename '#{expected}'") end def check_marketplace_entry if skip_marketplace_checks? return skip_check( "No --marketplace_file given and default location #{default_marketplace_file} not found; " \ 'skipping marketplace registration check' ) end return fail_check('Cannot check marketplace entry: plugin.json is missing or invalid') unless @plugin_json return fail_check("Marketplace file not found at #{marketplace_file_path}") unless File.exist?(marketplace_file_path) return fail_check("Marketplace file at #{marketplace_file_path} contains invalid JSON") unless @marketplace_data return fail_check("No marketplace entry found for plugin '#{@plugin_json['name']}' in #{marketplace_file_path}") unless @marketplace_entry pass_check("Found marketplace entry for '#{@plugin_json['name']}' in #{marketplace_file_path}") end def check_description_matches if skip_marketplace_checks? return skip_check( "No --marketplace_file given and default location #{default_marketplace_file} not found; " \ 'skipping description consistency check' ) end unless @plugin_json && @marketplace_entry return fail_check('Cannot check description: plugin.json or marketplace entry is unavailable') end plugin_description = @plugin_json['description'] marketplace_description = @marketplace_entry['description'] if plugin_description == marketplace_description pass_check('plugin.json description matches marketplace.json description byte-for-byte') else fail_check( "Description drift: plugin.json has #{plugin_description.inspect}, " \ "marketplace.json has #{marketplace_description.inspect}" ) end end def check_skill_md_present return pass_check('No skills/ directory present; nothing to check') unless Dir.exist?(skills_root) missing = skill_directories.reject { |dir| File.exist?(File.join(skills_root, dir, 'SKILL.md')) } return fail_check("Missing SKILL.md in skill directories: #{missing.join(', ')}") unless missing.empty? pass_check('All skill directories contain SKILL.md') end def check_skills_array_matches return fail_check('Cannot check skills array: plugin.json is missing or invalid') unless @plugin_json return pass_check('plugin.json declares no skills array; nothing to check') unless @plugin_json.key?('skills') declared = Array(@plugin_json['skills']).sort actual = skill_directories if declared == actual pass_check('plugin.json skills array matches skills/ directories') else fail_check("plugin.json skills array #{declared.inspect} does not match actual directories #{actual.inspect}") end end def check_marketplace_source if skip_marketplace_checks? return skip_check( "No --marketplace_file given and default location #{default_marketplace_file} not found; " \ 'skipping marketplace source check' ) end return fail_check('Cannot check marketplace source: marketplace entry is unavailable') unless @marketplace_entry actual = @marketplace_entry['source'] return fail_check('Marketplace entry has no source field') if blank?(actual) resolved = File.expand_path(actual, marketplace_root) expected = File.expand_path(plugin_path) if resolved == expected pass_check("Marketplace source '#{actual}' resolves to plugin directory") else fail_check( "Marketplace source #{actual.inspect} resolves to #{resolved.inspect}, " \ "which does not match plugin directory #{expected.inspect}" ) end end # The directory a marketplace entry's `source` is resolved relative to. # marketplace.json conventionally lives at /.claude-plugin/marketplace.json, # with sources relative to . If the marketplace file isn't nested under a # .claude-plugin/ directory (e.g. a flat fixture file), fall back to its own # containing directory. def marketplace_root dir = File.dirname(File.expand_path(marketplace_file_path)) File.basename(dir) == '.claude-plugin' ? File.dirname(dir) : dir end # ─── Helpers ──────────────────────────────────────────────────────────── def directory_basename File.basename(plugin_path.to_s.chomp('/')) end def blank?(value) value.nil? || (value.is_a?(String) && value.strip.empty?) end def pass_check(message) { status: 'pass', message: message } end def fail_check(message) { status: 'fail', message: message } end def skip_check(message) { status: 'skipped', message: message } end def build_result(checks) failed = checks.values.select { |check| check[:status] == 'fail' } { status: failed.empty? ? 'ok' : 'issues_found', valid: failed.empty?, plugin_path: plugin_path, checks: checks, issues: failed.map { |check| check[:message] } } end def missing_plugin_path_result { status: 'issues_found', valid: false, plugin_path: plugin_path, checks: {}, issues: ['No plugin_path given. Usage: validate --plugin_path='] } end end end end