263 lines
10 KiB
Ruby
263 lines
10 KiB
Ruby
# 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 <root>/.claude-plugin/marketplace.json,
|
|
# with sources relative to <root>. 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=<path-to-plugin>']
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end
|