Skip to content
Kward Search API index

Class: Kward::PluginRegistry

Inherits:
Object
  • Object
show all
Defined in:
lib/kward/plugins/registry.rb

Overview

Loads trusted user plugin files and provides the plugin DSL.

Plugins live in the user plugin directory, run as local Ruby code, and can register slash commands, namespaced actions, model-callable tools, lifecycle callbacks, composable status renderers, prompt context, and live transcript-event observers for CLI and RPC frontends.

This registry is intentionally trust-based, not a sandbox. Keep plugin loading restricted to ConfigFiles.plugin_paths, keep workspace-local code out of the load path, and expose immutable transcript views so plugins can observe state without corrupting active conversations.

Defined Under Namespace

Classes: Context, DSL, HookHandler, InteractiveCommand, LifecycleHandler, Status, StatusSegment, TabType, Tool, Transcript, TranscriptEvent, TransportType

Constant Summary collapse

PLUGIN_API_VERSION =
"1"
COMMAND_NAME_PATTERN =
/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/.freeze
Command =

Public registration types retained under the registry namespace.

PluginCommand
Action =
PluginAction
STATUS_PRIORITIES =
{ low: 0, normal: 1, high: 2 }.freeze
STATUS_SEPARATOR =
" · "

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(reserved_commands: [], warning_sink: nil) ⇒ PluginRegistry

Creates an object for trusted plugin loading and dispatch.



531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
# File 'lib/kward/plugins/registry.rb', line 531

def initialize(reserved_commands: [], warning_sink: nil)
  @reserved_commands = reserved_commands.map(&:to_s)
  @warning_sink = warning_sink
  @plugins = {}
  @commands = {}
  @actions = {}
  @tools = {}
  @interactive_commands = {}
  @tab_types = {}
  @tab_types_by_id = {}
  @transports = {}
  @transports_by_id = {}
  @statuses = {}
  @status_sequence = 0
  @footer_path = nil
  @transcript_event_handlers = []
  @prompt_context_renderers = []
  @hook_handlers = []
  @lifecycle_handlers = { start: [], reload: [], shutdown: [] }
  @lifecycle_state = :loaded
  @lifecycle_mutex = Mutex.new
  @paths = []
end

Class Attribute Details

.loading_path ⇒ Object

Returns the value of attribute loading_path.



519
520
521
# File 'lib/kward/plugins/registry.rb', line 519

def loading_path
  @loading_path
end

.loading_registry ⇒ Object

Returns the value of attribute loading_registry.



519
520
521
# File 'lib/kward/plugins/registry.rb', line 519

def loading_registry
  @loading_registry
end

Instance Attribute Details

Returns most recent plugin file to register legacy footer output.

Returns:

  • (String, nil) —

    most recent plugin file to register legacy footer output



556
557
558
# File 'lib/kward/plugins/registry.rb', line 556

def footer_path
  @footer_path
end

#paths ⇒ Array<String> (readonly)

Returns plugin files successfully loaded by this registry.

Returns:

  • (Array<String>) —

    plugin files successfully loaded by this registry



559
560
561
# File 'lib/kward/plugins/registry.rb', line 559

def paths
  @paths
end

Class Method Details

.load(paths: nil, reserved_commands: [], warning_sink: nil) ⇒ Object



521
522
523
524
525
526
527
# File 'lib/kward/plugins/registry.rb', line 521

def load(paths: nil, reserved_commands: [], warning_sink: nil)
  warning_sink ||= ConfigFiles.warning_sink
  paths ||= ConfigFiles.plugin_paths(warning_sink: warning_sink)
  registry = new(reserved_commands: reserved_commands, warning_sink: warning_sink)
  paths.each { |path| registry.load_file(path) }
  registry
end

Instance Method Details

#action_for(id) ⇒ Object



582
583
584
# File 'lib/kward/plugins/registry.rb', line 582

def action_for(id)
  @actions[id.to_s]
end

#actions ⇒ Object



578
579
580
# File 'lib/kward/plugins/registry.rb', line 578

def actions
  @actions.values
end

#command_for(name) ⇒ Object



574
575
576
# File 'lib/kward/plugins/registry.rb', line 574

def command_for(name)
  @commands[name.to_s]
end

#commands ⇒ Object



570
571
572
# File 'lib/kward/plugins/registry.rb', line 570

def commands
  @commands.values
end

#compose_status(segments, max_width: nil, &measure) ⇒ Object

Composes already-rendered segments. When max_width is supplied, complete low-priority segments are removed first; the caller remains responsible for truncating a final oversized segment according to frontend rules.



657
658
659
660
661
662
663
664
665
666
667
# File 'lib/kward/plugins/registry.rb', line 657

def compose_status(segments, max_width: nil, &measure)
  visible = Array(segments).dup
  return "" if visible.empty? || (!max_width.nil? && max_width.to_i <= 0)

  measure ||= ->(text) { text.to_s.length }
  width = max_width&.to_i
  while width && visible.length > 1 && measure.call(status_text(visible)) > width
    remove_lowest_priority_segment!(visible)
  end
  status_text(visible)
end

#emit_warning(message) ⇒ Object



960
961
962
# File 'lib/kward/plugins/registry.rb', line 960

def emit_warning(message)
  @warning_sink ? @warning_sink.call(message) : warn(message)
end

#evaluate(path: nil, id: nil, version: nil, api: nil, &block) ⇒ Object



747
748
749
750
751
752
# File 'lib/kward/plugins/registry.rb', line 747

def evaluate(path: nil, id: nil, version: nil, api: nil, &block)
  host = register_plugin_identity(id: id, version: version, api: api, path: path)
  dsl = DSL.new(self, path, host: host)
  block.arity == 1 ? block.call(dsl) : dsl.instance_eval(&block)
  self
end

Backward-compatible aggregate footer renderer.



635
636
637
638
639
640
641
# File 'lib/kward/plugins/registry.rb', line 635

def footer_renderer
  return nil unless status?

  lambda do |context|
    compose_status(status_segments(context))
  end
end

#hook_handlers ⇒ Object



677
678
679
# File 'lib/kward/plugins/registry.rb', line 677

def hook_handlers
  @hook_handlers.dup
end

#hook_manager ⇒ Object



700
701
702
703
704
705
706
707
708
# File 'lib/kward/plugins/registry.rb', line 700

def hook_manager
  manager = Hooks::Manager.new
  @hook_handlers.each do |hook|
    manager.register(hook.event, id: hook.id, source: hook.path, order: hook.order, match: hook.match, failure_policy: hook.failure_policy) do |event, context|
      hook.handler.call(event, context)
    end
  end
  manager
end

#interactive_command_for(name) ⇒ Object



598
599
600
# File 'lib/kward/plugins/registry.rb', line 598

def interactive_command_for(name)
  @interactive_commands[name.to_s]
end

#interactive_commands ⇒ Object



594
595
596
# File 'lib/kward/plugins/registry.rb', line 594

def interactive_commands
  @interactive_commands.values
end

#load_file(path) ⇒ Object



733
734
735
736
737
738
739
740
741
742
743
744
745
# File 'lib/kward/plugins/registry.rb', line 733

def load_file(path)
  previous_registry = self.class.loading_registry
  previous_path = self.class.loading_path
  self.class.loading_registry = self
  self.class.loading_path = path
  Kernel.load(path, true)
  @paths << path
rescue StandardError => e
  emit_warning "Warning: skipping Kward plugin #{path}: #{e.message}"
ensure
  self.class.loading_registry = previous_registry
  self.class.loading_path = previous_path
end

#notify_transcript_event(event, context) ⇒ Object



721
722
723
724
725
726
727
728
729
730
731
# File 'lib/kward/plugins/registry.rb', line 721

def notify_transcript_event(event, context)
  transcript_event = transcript_event_for(event)
  return unless transcript_event

  @transcript_event_handlers.each do |entry|
    entry[:handler].call(transcript_event, context)
  rescue StandardError => e
    emit_warning "Warning: Kward plugin transcript event error in #{entry[:path]}: #{e.message}"
  end
  nil
end

#plugin_for(id) ⇒ Object



566
567
568
# File 'lib/kward/plugins/registry.rb', line 566

def plugin_for(id)
  @plugins[id.to_s]
end

#plugins ⇒ Array<PluginHost>

Returns identified plugins loaded by this registry.

Returns:

  • (Array<PluginHost>) —

    identified plugins loaded by this registry



562
563
564
# File 'lib/kward/plugins/registry.rb', line 562

def plugins
  @plugins.values
end

#prompt_context(context) ⇒ Object



710
711
712
713
714
715
716
717
718
719
# File 'lib/kward/plugins/registry.rb', line 710

def prompt_context(context)
  parts = []
  @prompt_context_renderers.each do |entry|
    rendered = entry[:renderer].call(context)
    parts << rendered.to_s unless rendered.to_s.empty?
  rescue StandardError => e
    emit_warning "Warning: Kward plugin prompt context error in #{entry[:path]}: #{e.message}"
  end
  parts.empty? ? nil : parts.join("\n\n")
end

#prompt_context_renderers ⇒ Object



673
674
675
# File 'lib/kward/plugins/registry.rb', line 673

def prompt_context_renderers
  @prompt_context_renderers.map { |entry| entry[:renderer] }
end

#register_action(name, plugin_id:, description:, schema:, path: nil, &handler) ⇒ Object



800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
# File 'lib/kward/plugins/registry.rb', line 800

def register_action(name, plugin_id:, description:, schema:, path: nil, &handler)
  name = name.to_s
  raise "Plugin action name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
  raise "Plugin action #{plugin_id}/#{name} requires a description" if description.to_s.strip.empty?
  raise "Plugin action #{plugin_id}/#{name} requires a handler" unless handler

  id = "#{plugin_id}/#{name}"
  if @actions.key?(id)
    emit_warning "Warning: skipping duplicate Kward plugin action #{id}: #{path}"
    return nil
  end

  @actions[id] = Action.new(
    name: name,
    plugin_id: plugin_id,
    description: description.to_s,
    schema: schema,
    path: path,
    handler: handler
  )
end

#register_command(name, description: "", argument_hint: "", schema: nil, positionals: [], plugin_id: nil, path: nil, &handler) ⇒ Object

Raises:

  • (ArgumentError)


773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
# File 'lib/kward/plugins/registry.rb', line 773

def register_command(name, description: "", argument_hint: "", schema: nil, positionals: [], plugin_id: nil, path: nil, &handler)
  name = name.to_s
  raise "Plugin command name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
  raise "Plugin command /#{name} requires a handler" unless handler
  raise ArgumentError, "Plugin command /#{name} positionals require a schema" if schema.nil? && !Array(positionals).empty?

  if @reserved_commands.include?(name)
    emit_warning "Warning: skipping Kward plugin command /#{name}: reserved command"
    return nil
  end
  if @commands.key?(name)
    emit_warning "Warning: skipping duplicate Kward plugin command /#{name}: #{path}"
    return nil
  end

  @commands[name] = Command.new(
    name: name,
    description: description.to_s,
    argument_hint: argument_hint.to_s,
    schema: schema,
    positionals: positionals,
    plugin_id: plugin_id,
    path: path,
    handler: handler
  )
end


916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
# File 'lib/kward/plugins/registry.rb', line 916

def register_footer(plugin_id: nil, path: nil, &renderer)
  raise "Plugin footer requires a renderer" unless renderer

  id = if plugin_id
         "#{plugin_id}/footer"
       else
         available_legacy_status_id("legacy/#{legacy_status_source(path)}")
       end
  @status_sequence += 1 unless @statuses.key?(id)
  @statuses[id] = Status.new(
    id: id,
    order: 100,
    priority: :normal,
    path: path,
    renderer: renderer,
    sequence: @statuses[id]&.sequence || @status_sequence
  )
  @footer_path = path
end

#register_hook(event, id: nil, description: "", order: 100, match: nil, failure_policy: nil, path: nil, &handler) ⇒ Object



984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
# File 'lib/kward/plugins/registry.rb', line 984

def register_hook(event, id: nil, description: "", order: 100, match: nil, failure_policy: nil, path: nil, &handler)
  event = event.to_s
  raise "Plugin hook event is required" if event.empty?
  raise "Plugin hook #{event} requires a handler" unless handler

  @hook_handlers << HookHandler.new(
    event: event,
    id: id&.to_s || "#{File.basename(path.to_s.empty? ? "plugin" : path)}:#{event}:#{@hook_handlers.length + 1}",
    description: description.to_s,
    path: path,
    order: order.to_i,
    match: match,
    failure_policy: failure_policy,
    handler: handler
  )
end

#register_interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", path: nil, &handler) ⇒ Object



842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
# File 'lib/kward/plugins/registry.rb', line 842

def register_interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", path: nil, &handler)
  name = name.to_s
  raise "Interactive command name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
  raise "Interactive command /#{name} requires a handler" unless handler

  if @reserved_commands.include?(name) || @commands.key?(name)
    emit_warning "Warning: skipping Kward interactive command /#{name}: reserved command"
    return nil
  end
  if @interactive_commands.key?(name)
    emit_warning "Warning: skipping duplicate Kward interactive command /#{name}: #{path}"
    return nil
  end

  @interactive_commands[name] = InteractiveCommand.new(
    name: name,
    description: description.to_s,
    argument_hint: argument_hint.to_s,
    rows: [[rows.to_i, 1].max, 1].max,
    fps: [[fps.to_f, 1].max, 120].min,
    path: path,
    handler: handler
  )
end

#register_lifecycle(event, host:, path: nil, &handler) ⇒ Object

Raises:

  • (ArgumentError)


976
977
978
979
980
981
982
# File 'lib/kward/plugins/registry.rb', line 976

def register_lifecycle(event, host:, path: nil, &handler)
  event = event.to_sym
  raise ArgumentError, "Unknown plugin lifecycle event: #{event}" unless @lifecycle_handlers.key?(event)
  raise ArgumentError, "Plugin lifecycle #{event} requires a handler" unless handler

  @lifecycle_handlers[event] << LifecycleHandler.new(event: event, host: host, path: path, handler: handler)
end

#register_plugin_identity(id:, version:, api:, path: nil) ⇒ Object

Raises:

  • (ArgumentError)


754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
# File 'lib/kward/plugins/registry.rb', line 754

def register_plugin_identity(id:, version:, api:, path: nil)
  values = [id, version, api]
  return nil if values.all?(&:nil?)
  raise ArgumentError, "Plugin id, version, and api are required together" if values.any?(&:nil?)

  id = id.to_s
  api = api.to_s
  raise ArgumentError, "Unsupported Kward plugin API #{api.inspect} for #{id}; supported API: #{PLUGIN_API_VERSION}" unless api == PLUGIN_API_VERSION
  raise ArgumentError, "Duplicate Kward plugin id: #{id}" if @plugins.key?(id)

  @plugins[id] = PluginHost.new(
    id: id,
    version: version,
    api_version: api,
    source_path: path,
    warning_sink: method(:emit_warning)
  )
end

#register_prompt_context(path: nil, &renderer) ⇒ Object



970
971
972
973
974
# File 'lib/kward/plugins/registry.rb', line 970

def register_prompt_context(path: nil, &renderer)
  raise "Plugin prompt context requires a renderer" unless renderer

  @prompt_context_renderers << { path: path, renderer: renderer }
end

#register_status(name, plugin_id:, order: 100, priority: :normal, path: nil, &renderer) ⇒ Object



936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
# File 'lib/kward/plugins/registry.rb', line 936

def register_status(name, plugin_id:, order: 100, priority: :normal, path: nil, &renderer)
  name = name.to_s
  raise "Plugin status name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
  raise "Plugin status #{plugin_id}/#{name} requires a renderer" unless renderer

  id = "#{plugin_id}/#{name}"
  if @statuses.key?(id)
    emit_warning "Warning: skipping duplicate Kward plugin status #{id}: #{path}"
    return nil
  end

  order = Integer(order)
  priority = normalize_status_priority(priority)
  @status_sequence += 1
  @statuses[id] = Status.new(
    id: id,
    order: order,
    priority: priority,
    path: path,
    renderer: renderer,
    sequence: @status_sequence
  )
end

#register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transport: false, local: true, transcript_events: false, api: nil, capabilities: nil, plugin_id: nil, path: nil, &handler) ⇒ Object



867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
# File 'lib/kward/plugins/registry.rb', line 867

def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transport: false, local: true, transcript_events: false, api: nil, capabilities: nil, plugin_id: nil, path: nil, &handler)
  name = name.to_s
  id = id.to_s
  raise "Plugin tab type name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
  raise "Plugin tab type id is required" if id.empty?
  raise "Plugin tab type #{name} requires a handler" unless handler

  if @tab_types.key?(name) || @tab_types_by_id.key?(id)
    emit_warning "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
    return nil
  end

  tab_type = TabType.new(
    id: id,
    name: name,
    title: title.to_s.empty? ? name.capitalize : title.to_s,
    singleton: singleton&.to_sym,
    rpc: rpc == true,
    transport: transport == true,
    local: local == true,
    transcript_events: transcript_events == true,
    capabilities: PluginChatCapabilities.build(api: api, capabilities: capabilities),
    plugin_id: plugin_id,
    path: path,
    handler: handler
  )
  @tab_types[name] = tab_type
  @tab_types_by_id[id] = tab_type
end

#register_tool(name, description:, schema:, path: nil, &handler) ⇒ Object



822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
# File 'lib/kward/plugins/registry.rb', line 822

def register_tool(name, description:, schema:, path: nil, &handler)
  name = name.to_s
  raise "Plugin tool name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
  raise "Plugin tool #{name} requires a description" if description.to_s.strip.empty?
  raise "Plugin tool #{name} requires a handler" unless handler

  if @tools.key?(name)
    emit_warning "Warning: skipping duplicate Kward plugin tool #{name}: #{path}"
    return nil
  end

  @tools[name] = Tool.new(
    name: name,
    description: description.to_s,
    schema: normalize_tool_schema(name, schema),
    path: path,
    handler: handler
  )
end

#register_transcript_event(path: nil, &handler) ⇒ Object



964
965
966
967
968
# File 'lib/kward/plugins/registry.rb', line 964

def register_transcript_event(path: nil, &handler)
  raise "Plugin transcript event requires a handler" unless handler

  @transcript_event_handlers << { path: path, handler: handler }
end

#register_transport(name, id:, capabilities: nil, execution_profile: nil, path: nil, &handler) ⇒ Object



897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
# File 'lib/kward/plugins/registry.rb', line 897

def register_transport(name, id:, capabilities: nil, execution_profile: nil, path: nil, &handler)
  name = name.to_s
  id = id.to_s
  raise "Plugin transport name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
  raise "Plugin transport id is required" if id.empty?
  raise "Plugin transport #{name} requires a handler" unless handler

  if @transports.key?(name) || @transports_by_id.key?(id)
    emit_warning "Warning: skipping duplicate Kward plugin transport #{id}: #{path}"
    return nil
  end

  capabilities = normalize_transport_capabilities(capabilities)
  execution_profile = normalize_execution_profile(execution_profile)
  transport = TransportType.new(id: id, name: name, capabilities: capabilities, execution_profile: execution_profile, path: path, handler: handler)
  @transports[name] = transport
  @transports_by_id[id] = transport
end

#reload!(timeout: PluginResources::DEFAULT_SHUTDOWN_TIMEOUT) ⇒ Object

Invokes reload callbacks on the old registry and cleans up all resources.



691
692
693
# File 'lib/kward/plugins/registry.rb', line 691

def reload!(timeout: PluginResources::DEFAULT_SHUTDOWN_TIMEOUT)
  stop_lifecycle!(:reload, timeout: timeout)
end

#shutdown!(timeout: PluginResources::DEFAULT_SHUTDOWN_TIMEOUT) ⇒ Object

Invokes shutdown callbacks and cleans up all resources.



696
697
698
# File 'lib/kward/plugins/registry.rb', line 696

def shutdown!(timeout: PluginResources::DEFAULT_SHUTDOWN_TIMEOUT)
  stop_lifecycle!(:shutdown, timeout: timeout)
end

#start! ⇒ Object

Activates identified plugins and invokes their start callbacks once.



682
683
684
685
686
687
688
# File 'lib/kward/plugins/registry.rb', line 682

def start!
  transition_lifecycle!(:loaded, :active) do
    @plugins.each_value(&:activate!)
    run_lifecycle_callbacks(:start)
  end
  self
end

#status? ⇒ Boolean

Returns:

  • (Boolean)


630
631
632
# File 'lib/kward/plugins/registry.rb', line 630

def status?
  !@statuses.empty?
end

#status_segments(context) ⇒ Object

Evaluates every status renderer independently and returns display-ordered frontend-neutral segments. A broken renderer cannot hide healthy segments.



645
646
647
648
649
650
651
652
# File 'lib/kward/plugins/registry.rb', line 645

def status_segments(context)
  @statuses.values.sort_by { |status| [status.order, status.sequence] }.filter_map do |status|
    normalize_status_segment(status, status.renderer.call(context))
  rescue StandardError => e
    emit_warning "Warning: Kward plugin status #{status.id} error in #{status.path}: #{e.message}"
    nil
  end
end

#tab_type_for(name) ⇒ Object



606
607
608
# File 'lib/kward/plugins/registry.rb', line 606

def tab_type_for(name)
  @tab_types[name.to_s]
end

#tab_type_for_id(id) ⇒ Object



610
611
612
# File 'lib/kward/plugins/registry.rb', line 610

def tab_type_for_id(id)
  @tab_types_by_id[id.to_s]
end

#tab_types ⇒ Object



602
603
604
# File 'lib/kward/plugins/registry.rb', line 602

def tab_types
  @tab_types.values
end

#tool_for(name) ⇒ Object



590
591
592
# File 'lib/kward/plugins/registry.rb', line 590

def tool_for(name)
  @tools[name.to_s]
end

#tools ⇒ Object



586
587
588
# File 'lib/kward/plugins/registry.rb', line 586

def tools
  @tools.values
end

#transcript_event_handlers ⇒ Object



669
670
671
# File 'lib/kward/plugins/registry.rb', line 669

def transcript_event_handlers
  @transcript_event_handlers.map { |entry| entry[:handler] }
end

#transport_for(name) ⇒ Object



622
623
624
# File 'lib/kward/plugins/registry.rb', line 622

def transport_for(name)
  @transports[name.to_s]
end

#transport_for_id(id) ⇒ Object



626
627
628
# File 'lib/kward/plugins/registry.rb', line 626

def transport_for_id(id)
  @transports_by_id[id.to_s]
end

#transport_tab_types ⇒ Object



614
615
616
# File 'lib/kward/plugins/registry.rb', line 614

def transport_tab_types
  @tab_types.values.select(&:transport)
end

#transports ⇒ Object



618
619
620
# File 'lib/kward/plugins/registry.rb', line 618

def transports
  @transports.values
end