spurline-deploy 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +7 -0
  2. data/lib/spurline/adapters/base.rb +17 -0
  3. data/lib/spurline/adapters/claude.rb +208 -0
  4. data/lib/spurline/adapters/open_ai.rb +213 -0
  5. data/lib/spurline/adapters/registry.rb +33 -0
  6. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  7. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  8. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  9. data/lib/spurline/agent.rb +433 -0
  10. data/lib/spurline/audit/log.rb +156 -0
  11. data/lib/spurline/audit/secret_filter.rb +121 -0
  12. data/lib/spurline/base.rb +130 -0
  13. data/lib/spurline/cartographer/analyzer.rb +71 -0
  14. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  15. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  16. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  17. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  18. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  19. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  20. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  21. data/lib/spurline/cartographer/runner.rb +88 -0
  22. data/lib/spurline/cartographer.rb +6 -0
  23. data/lib/spurline/channels/base.rb +41 -0
  24. data/lib/spurline/channels/event.rb +136 -0
  25. data/lib/spurline/channels/github.rb +205 -0
  26. data/lib/spurline/channels/router.rb +103 -0
  27. data/lib/spurline/cli/check.rb +88 -0
  28. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  29. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  30. data/lib/spurline/cli/checks/base.rb +35 -0
  31. data/lib/spurline/cli/checks/credentials.rb +43 -0
  32. data/lib/spurline/cli/checks/permissions.rb +22 -0
  33. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  34. data/lib/spurline/cli/checks/session_store.rb +97 -0
  35. data/lib/spurline/cli/console.rb +73 -0
  36. data/lib/spurline/cli/credentials.rb +181 -0
  37. data/lib/spurline/cli/generators/agent.rb +123 -0
  38. data/lib/spurline/cli/generators/migration.rb +62 -0
  39. data/lib/spurline/cli/generators/project.rb +331 -0
  40. data/lib/spurline/cli/generators/tool.rb +98 -0
  41. data/lib/spurline/cli/router.rb +121 -0
  42. data/lib/spurline/configuration.rb +23 -0
  43. data/lib/spurline/dsl/guardrails.rb +108 -0
  44. data/lib/spurline/dsl/hooks.rb +51 -0
  45. data/lib/spurline/dsl/memory.rb +39 -0
  46. data/lib/spurline/dsl/model.rb +23 -0
  47. data/lib/spurline/dsl/persona.rb +74 -0
  48. data/lib/spurline/dsl/suspend_until.rb +53 -0
  49. data/lib/spurline/dsl/tools.rb +176 -0
  50. data/lib/spurline/errors.rb +109 -0
  51. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  52. data/lib/spurline/lifecycle/runner.rb +456 -0
  53. data/lib/spurline/lifecycle/states.rb +47 -0
  54. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  55. data/lib/spurline/memory/context_assembler.rb +100 -0
  56. data/lib/spurline/memory/embedder/base.rb +17 -0
  57. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  58. data/lib/spurline/memory/episode.rb +56 -0
  59. data/lib/spurline/memory/episodic_store.rb +147 -0
  60. data/lib/spurline/memory/long_term/base.rb +22 -0
  61. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  62. data/lib/spurline/memory/manager.rb +147 -0
  63. data/lib/spurline/memory/short_term.rb +57 -0
  64. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  65. data/lib/spurline/orchestration/judge.rb +109 -0
  66. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  67. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  68. data/lib/spurline/orchestration/ledger.rb +339 -0
  69. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  70. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  71. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  72. data/lib/spurline/persona/base.rb +42 -0
  73. data/lib/spurline/persona/registry.rb +42 -0
  74. data/lib/spurline/secrets/resolver.rb +65 -0
  75. data/lib/spurline/secrets/vault.rb +42 -0
  76. data/lib/spurline/security/content.rb +76 -0
  77. data/lib/spurline/security/context_pipeline.rb +58 -0
  78. data/lib/spurline/security/gates/base.rb +36 -0
  79. data/lib/spurline/security/gates/operator_config.rb +22 -0
  80. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  81. data/lib/spurline/security/gates/tool_result.rb +23 -0
  82. data/lib/spurline/security/gates/user_input.rb +22 -0
  83. data/lib/spurline/security/injection_scanner.rb +109 -0
  84. data/lib/spurline/security/pii_filter.rb +104 -0
  85. data/lib/spurline/session/resumption.rb +36 -0
  86. data/lib/spurline/session/serializer.rb +169 -0
  87. data/lib/spurline/session/session.rb +154 -0
  88. data/lib/spurline/session/store/base.rb +27 -0
  89. data/lib/spurline/session/store/memory.rb +45 -0
  90. data/lib/spurline/session/store/postgres.rb +123 -0
  91. data/lib/spurline/session/store/sqlite.rb +139 -0
  92. data/lib/spurline/session/suspension.rb +93 -0
  93. data/lib/spurline/session/turn.rb +98 -0
  94. data/lib/spurline/spur.rb +213 -0
  95. data/lib/spurline/streaming/buffer.rb +77 -0
  96. data/lib/spurline/streaming/chunk.rb +62 -0
  97. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  98. data/lib/spurline/testing.rb +245 -0
  99. data/lib/spurline/toolkit.rb +110 -0
  100. data/lib/spurline/tools/base.rb +209 -0
  101. data/lib/spurline/tools/idempotency.rb +220 -0
  102. data/lib/spurline/tools/permissions.rb +44 -0
  103. data/lib/spurline/tools/registry.rb +43 -0
  104. data/lib/spurline/tools/runner.rb +255 -0
  105. data/lib/spurline/tools/scope.rb +309 -0
  106. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  107. data/lib/spurline/version.rb +5 -0
  108. data/lib/spurline.rb +56 -0
  109. metadata +161 -0
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Streaming
5
+ # Accumulates streaming chunks to detect tool call boundaries.
6
+ # Text chunks are yielded immediately to the caller.
7
+ # Tool calls are only dispatched when the full argument payload has arrived.
8
+ #
9
+ # Handles edge cases:
10
+ # - Multiple tool calls in a single response
11
+ # - Partial JSON arguments across chunks
12
+ # - Mixed text and tool call responses
13
+ class Buffer
14
+ attr_reader :chunks
15
+
16
+ def initialize
17
+ @chunks = []
18
+ @stop_reason = nil
19
+ end
20
+
21
+ def <<(chunk)
22
+ @chunks << chunk
23
+ @stop_reason = chunk.metadata[:stop_reason] if chunk.done?
24
+ self
25
+ end
26
+
27
+ def complete?
28
+ @chunks.any?(&:done?)
29
+ end
30
+
31
+ def tool_call?
32
+ @stop_reason == "tool_use"
33
+ end
34
+
35
+ def text_chunks
36
+ @chunks.select(&:text?)
37
+ end
38
+
39
+ def full_text
40
+ text_chunks.map(&:text).join
41
+ end
42
+
43
+ # Returns all tool call data from metadata.
44
+ # Supports multiple tool calls in a single response.
45
+ def tool_calls
46
+ calls = @chunks
47
+ .select { |c| c.metadata[:tool_call] }
48
+ .map { |c| c.metadata[:tool_call] }
49
+
50
+ # Deduplicate by name+arguments (guards against duplicate chunk delivery)
51
+ calls.uniq { |c| [c[:name], c[:arguments]] }
52
+ end
53
+
54
+ def tool_call_count
55
+ tool_calls.length
56
+ end
57
+
58
+ # The stop reason from the done chunk.
59
+ def stop_reason
60
+ @stop_reason
61
+ end
62
+
63
+ def clear!
64
+ @chunks = []
65
+ @stop_reason = nil
66
+ end
67
+
68
+ def size
69
+ @chunks.length
70
+ end
71
+
72
+ def empty?
73
+ @chunks.empty?
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Streaming
5
+ # A typed chunk of streaming output. Never a raw string.
6
+ #
7
+ # Types:
8
+ # :text — text content from the LLM
9
+ # :tool_start — a tool execution is beginning
10
+ # :tool_end — a tool execution has completed
11
+ # :done — the stream is complete
12
+ class Chunk
13
+ TYPES = %i[text tool_start tool_end done].freeze
14
+
15
+ attr_reader :type, :text, :turn, :session_id, :metadata
16
+
17
+ def initialize(type:, text: nil, turn: nil, session_id: nil, metadata: {})
18
+ validate_type!(type)
19
+
20
+ @type = type
21
+ @text = text&.dup&.freeze
22
+ @turn = turn
23
+ @session_id = session_id
24
+ @metadata = metadata.freeze
25
+ freeze
26
+ end
27
+
28
+ def text?
29
+ type == :text
30
+ end
31
+
32
+ def tool_start?
33
+ type == :tool_start
34
+ end
35
+
36
+ def tool_end?
37
+ type == :tool_end
38
+ end
39
+
40
+ def done?
41
+ type == :done
42
+ end
43
+
44
+ def inspect
45
+ parts = ["type=#{type}"]
46
+ parts << "text=#{text[0..30].inspect}#{text.length > 30 ? "..." : ""}" if text
47
+ parts << "turn=#{turn}" if turn
48
+ "#<Spurline::Streaming::Chunk #{parts.join(" ")}>"
49
+ end
50
+
51
+ private
52
+
53
+ def validate_type!(type)
54
+ return if TYPES.include?(type)
55
+
56
+ raise Spurline::ConfigurationError,
57
+ "Invalid chunk type: #{type.inspect}. " \
58
+ "Must be one of: #{TYPES.map(&:inspect).join(", ")}."
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Streaming
5
+ # Wraps a block-based streaming interface into a Ruby Enumerator.
6
+ # This allows both block and enumerator usage patterns (ADR-001):
7
+ #
8
+ # agent.run("hello") { |chunk| print chunk.text }
9
+ # agent.run("hello").each { |chunk| print chunk.text }
10
+ #
11
+ class StreamEnumerator
12
+ include Enumerable
13
+
14
+ def initialize(&producer)
15
+ @producer = producer
16
+ end
17
+
18
+ def each(&consumer)
19
+ if consumer
20
+ @producer.call(consumer)
21
+ else
22
+ ::Enumerator.new do |yielder|
23
+ @producer.call(proc { |chunk| yielder << chunk })
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ # Test helpers for Spurline agents. Require in your spec_helper:
5
+ #
6
+ # require "spurline/testing"
7
+ #
8
+ # Then include in your specs:
9
+ #
10
+ # include Spurline::Testing
11
+ #
12
+ # Or let the auto-configuration handle it (included globally if RSpec is loaded).
13
+ module Testing
14
+ TOOL_SOURCE_PATTERN = /source=["']tool:(?<tool_name>[^"']+)["']/.freeze
15
+
16
+ # Creates a stub text response that streams as chunks.
17
+ def stub_text(text, turn: 1)
18
+ chunks = text.chars.each_slice(5).map do |chars|
19
+ Spurline::Streaming::Chunk.new(
20
+ type: :text,
21
+ text: chars.join,
22
+ turn: turn
23
+ )
24
+ end
25
+
26
+ chunks << Spurline::Streaming::Chunk.new(
27
+ type: :done,
28
+ turn: turn,
29
+ metadata: { stop_reason: "end_turn" }
30
+ )
31
+
32
+ { type: :text, text: text, chunks: chunks }
33
+ end
34
+
35
+ # Creates a stub tool call response.
36
+ def stub_tool_call(tool_name, turn: 1, **arguments)
37
+ tool_call_data = { name: tool_name.to_s, arguments: arguments }
38
+
39
+ chunks = [
40
+ Spurline::Streaming::Chunk.new(
41
+ type: :tool_start,
42
+ turn: turn,
43
+ metadata: { tool_name: tool_name.to_s, arguments: arguments }
44
+ ),
45
+ Spurline::Streaming::Chunk.new(
46
+ type: :done,
47
+ turn: turn,
48
+ metadata: {
49
+ stop_reason: "tool_use",
50
+ tool_call: tool_call_data,
51
+ }
52
+ ),
53
+ ]
54
+
55
+ { type: :tool_call, tool_call: tool_call_data, chunks: chunks }
56
+ end
57
+
58
+ # Asserts that a tool was called, optionally with matching arguments.
59
+ #
60
+ # Sources checked in order:
61
+ # 1) audit_log tool_call events
62
+ # 2) session turn tool_calls
63
+ # 3) StubAdapter call history (tool presence only)
64
+ #
65
+ # @param tool_name [Symbol, String]
66
+ # @param with [Hash] expected argument subset
67
+ # @param agent [Spurline::Agent, nil]
68
+ # @param adapter [Spurline::Adapters::StubAdapter, nil]
69
+ # @param audit_log [Spurline::Audit::Log, nil]
70
+ # @param session [Spurline::Session::Session, nil]
71
+ # @return [true]
72
+ def assert_tool_called(tool_name, with: {}, agent: nil, adapter: nil, audit_log: nil, session: nil)
73
+ tool = tool_name.to_s
74
+ expected_arguments = deep_symbolize(with || {})
75
+ audit_entries, session_entries, adapter_instance = resolve_tool_call_sources(
76
+ agent: agent,
77
+ adapter: adapter,
78
+ audit_log: audit_log,
79
+ session: session
80
+ )
81
+
82
+ matched = match_tool_call(tool, expected_arguments, audit_entries, key: :tool) ||
83
+ match_tool_call(tool, expected_arguments, session_entries, key: :name)
84
+ return true if matched
85
+
86
+ if tool_called_in_history?(tool, audit_entries, key: :tool) ||
87
+ tool_called_in_history?(tool, session_entries, key: :name)
88
+ raise_expectation!(
89
+ "Expected tool '#{tool}' to be called#{format_expected_arguments(expected_arguments)}."
90
+ )
91
+ end
92
+
93
+ if tool_called_in_adapter_history?(adapter_instance, tool)
94
+ if expected_arguments.empty?
95
+ return true
96
+ end
97
+
98
+ raise_expectation!(
99
+ "Tool '#{tool}' was detected in StubAdapter call history, but argument assertions " \
100
+ "require session or audit history. Pass `agent:`, `session:`, or `audit_log:`."
101
+ )
102
+ end
103
+
104
+ raise_expectation!(
105
+ "Expected tool '#{tool}' to be called#{format_expected_arguments(expected_arguments)}."
106
+ )
107
+ end
108
+
109
+ # Asserts that no injection detection error is raised while evaluating the block.
110
+ #
111
+ # @yield A call that runs through the context pipeline.
112
+ # @return [true]
113
+ def expect_no_injection
114
+ raise ArgumentError, "expect_no_injection requires a block" unless block_given?
115
+
116
+ yield
117
+ true
118
+ rescue *injection_error_classes => e
119
+ raise_expectation!(
120
+ "Expected no injection detection errors, but #{e.class.name} was raised: #{e.message}"
121
+ )
122
+ end
123
+
124
+ # Asserts that a Content object carries the expected trust level.
125
+ #
126
+ # @param content [Spurline::Security::Content]
127
+ # @param expected_trust [Symbol, String]
128
+ # @return [true]
129
+ def assert_trust_level(content, expected_trust)
130
+ unless content.is_a?(Spurline::Security::Content)
131
+ raise_expectation!(
132
+ "Expected Spurline::Security::Content, got #{content.class.name}."
133
+ )
134
+ end
135
+
136
+ expected = expected_trust.to_sym
137
+ actual = content.trust
138
+ return true if actual == expected
139
+
140
+ raise_expectation!("Expected trust level #{expected.inspect}, got #{actual.inspect}.")
141
+ end
142
+
143
+ private
144
+
145
+ def resolve_tool_call_sources(agent:, adapter:, audit_log:, session:)
146
+ audit = audit_log
147
+ sess = session
148
+ adapter_instance = adapter
149
+
150
+ if agent
151
+ audit ||= agent.respond_to?(:audit_log) ? agent.audit_log : nil
152
+ sess ||= agent.respond_to?(:session) ? agent.session : nil
153
+ if adapter_instance.nil? && agent.instance_variable_defined?(:@adapter)
154
+ adapter_instance = agent.instance_variable_get(:@adapter)
155
+ end
156
+ end
157
+
158
+ audit_entries = audit.respond_to?(:tool_calls) ? audit.tool_calls : []
159
+ session_entries = sess.respond_to?(:tool_calls) ? sess.tool_calls : []
160
+
161
+ [audit_entries, session_entries, adapter_instance]
162
+ end
163
+
164
+ def match_tool_call(tool, expected_arguments, entries, key:)
165
+ entries.find do |entry|
166
+ next false unless entry[key].to_s == tool
167
+
168
+ actual_arguments = deep_symbolize(entry[:arguments] || {})
169
+ hash_subset?(actual_arguments, expected_arguments)
170
+ end
171
+ end
172
+
173
+ def tool_called_in_history?(tool, entries, key:)
174
+ entries.any? { |entry| entry[key].to_s == tool }
175
+ end
176
+
177
+ def tool_called_in_adapter_history?(adapter, tool)
178
+ return false unless adapter.respond_to?(:calls)
179
+
180
+ adapter.calls.any? do |call|
181
+ messages = Array(call[:messages])
182
+ messages.any? do |message|
183
+ content = message[:content].to_s
184
+ match = TOOL_SOURCE_PATTERN.match(content)
185
+ match && match[:tool_name] == tool
186
+ end
187
+ end
188
+ end
189
+
190
+ def hash_subset?(actual, expected)
191
+ return true if expected.empty?
192
+ return false unless actual.is_a?(Hash)
193
+
194
+ expected.all? do |key, value|
195
+ actual_value = actual[key]
196
+ if value.is_a?(Hash)
197
+ hash_subset?(actual_value, value)
198
+ else
199
+ actual_value == value
200
+ end
201
+ end
202
+ end
203
+
204
+ def deep_symbolize(value)
205
+ case value
206
+ when Hash
207
+ value.each_with_object({}) do |(key, nested), hash|
208
+ hash[key.to_sym] = deep_symbolize(nested)
209
+ end
210
+ when Array
211
+ value.map { |item| deep_symbolize(item) }
212
+ else
213
+ value
214
+ end
215
+ end
216
+
217
+ def format_expected_arguments(arguments)
218
+ return "" if arguments.empty?
219
+
220
+ " with arguments #{arguments.inspect}"
221
+ end
222
+
223
+ def injection_error_classes
224
+ classes = []
225
+ classes << Spurline::InjectionAttemptError if defined?(Spurline::InjectionAttemptError)
226
+ classes << Spurline::InjectionDetectedError if defined?(Spurline::InjectionDetectedError)
227
+ classes
228
+ end
229
+
230
+ def raise_expectation!(message)
231
+ if defined?(RSpec::Expectations::ExpectationNotMetError)
232
+ raise RSpec::Expectations::ExpectationNotMetError, message
233
+ end
234
+
235
+ raise RuntimeError, message
236
+ end
237
+ end
238
+ end
239
+
240
+ # Auto-include in RSpec if available
241
+ if defined?(RSpec)
242
+ RSpec.configure do |config|
243
+ config.include Spurline::Testing
244
+ end
245
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ # A named group of tools with optional shared configuration.
5
+ # Toolkits own their tools — tools register through their toolkit,
6
+ # not independently. Tools remain leaf nodes (ADR-003).
7
+ #
8
+ # Three patterns for adding tools:
9
+ #
10
+ # # Pattern 1: External class (complex tools in their own file)
11
+ # class GitToolkit < Spurline::Toolkit
12
+ # toolkit_name :git
13
+ # tool GitCommit
14
+ # tool GitChangedFiles
15
+ # shared_config scoped: true
16
+ # end
17
+ #
18
+ # # Pattern 2: Inline definition (small tools)
19
+ # class CommsToolkit < Spurline::Toolkit
20
+ # toolkit_name :comms
21
+ # tool :send_message do
22
+ # description "Send a Teams message"
23
+ # parameters(type: "object", properties: { text: { type: "string" } })
24
+ # def call(text:)
25
+ # # implementation
26
+ # end
27
+ # end
28
+ # end
29
+ #
30
+ # # Pattern 3: Standalone tools (one-offs, no toolkit)
31
+ # # Register directly via tool_registry.register(:name, ToolClass)
32
+ #
33
+ class Toolkit
34
+ class << self
35
+ # Set or get the toolkit name. Inferred from class name if not set.
36
+ def toolkit_name(name = nil)
37
+ if name
38
+ @toolkit_name = name.to_sym
39
+ else
40
+ @toolkit_name || infer_name
41
+ end
42
+ end
43
+
44
+ # Register a tool in this toolkit.
45
+ #
46
+ # External class:
47
+ # tool GitCommit
48
+ #
49
+ # Inline definition:
50
+ # tool :send_message do
51
+ # description "..."
52
+ # parameters(...)
53
+ # def call(...); end
54
+ # end
55
+ def tool(tool_class_or_name = nil, &block)
56
+ @tool_entries ||= []
57
+
58
+ if block
59
+ name = tool_class_or_name.to_sym
60
+ klass = Class.new(Spurline::Tools::Base) do
61
+ tool_name name
62
+ end
63
+ klass.class_eval(&block)
64
+ @tool_entries << { name: name, tool_class: klass }
65
+ else
66
+ klass = tool_class_or_name
67
+ raise Spurline::ConfigurationError,
68
+ "Toolkit :#{toolkit_name} — `tool` expects a Tool class or a name with a block. " \
69
+ "Got: #{klass.inspect}" unless klass.is_a?(Class) && klass < Spurline::Tools::Base
70
+
71
+ @tool_entries << { name: klass.tool_name, tool_class: klass }
72
+ end
73
+ end
74
+
75
+ # Returns tool name symbols for all tools in this toolkit.
76
+ def tools
77
+ (@tool_entries || []).map { |e| e[:name] }
78
+ end
79
+
80
+ # Returns { name => ToolClass } for registration into a tool registry.
81
+ def tool_classes
82
+ (@tool_entries || []).each_with_object({}) { |e, h| h[e[:name]] = e[:tool_class] }
83
+ end
84
+
85
+ # Shared configuration applied to every tool when this toolkit is
86
+ # included in an agent. Supports the same keys as per-tool config:
87
+ # requires_confirmation, scoped, timeout, denied, allowed_users.
88
+ def shared_config(**opts)
89
+ if opts.any?
90
+ @shared_config ||= {}
91
+ @shared_config.merge!(opts)
92
+ end
93
+ @shared_config&.dup || {}
94
+ end
95
+
96
+ private
97
+
98
+ def infer_name
99
+ short = name&.split("::")&.last
100
+ return :unnamed unless short
101
+
102
+ short
103
+ .gsub(/Toolkit$/, "")
104
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
105
+ .downcase
106
+ .to_sym
107
+ end
108
+ end
109
+ end
110
+ end