spurline-docs 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 +160 -0
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+ require "set"
3
+
4
+ module Spurline
5
+ module Tools
6
+ # Base class for all Spurline tools. Tools are atomic — they cannot invoke
7
+ # other tools (ADR-003). Composition belongs in the Skill layer.
8
+ #
9
+ # Subclasses must implement #call with keyword arguments matching
10
+ # the tool's parameter schema.
11
+ class Base
12
+ class << self
13
+ def tool_name(name = nil)
14
+ if name
15
+ @tool_name = name.to_sym
16
+ else
17
+ @tool_name || self.name&.split("::")&.last&.gsub(/([a-z])([A-Z])/, '\1_\2')&.downcase&.to_sym
18
+ end
19
+ end
20
+
21
+ def description(desc = nil)
22
+ if desc
23
+ @description = desc
24
+ else
25
+ @description || ""
26
+ end
27
+ end
28
+
29
+ def parameters(params = nil)
30
+ if params
31
+ @parameters = params
32
+ else
33
+ @parameters || {}
34
+ end
35
+ end
36
+
37
+ # Declares a secret this tool needs injected at execution time.
38
+ # Secrets are resolved by the framework and are not part of the tool schema.
39
+ def secret(name, description: nil)
40
+ @declared_secrets ||= []
41
+ @declared_secrets << { name: name.to_sym, description: description }
42
+ end
43
+
44
+ # Returns all declared secrets for this class, including inherited ones.
45
+ def declared_secrets
46
+ own = @declared_secrets || []
47
+ if superclass.respond_to?(:declared_secrets)
48
+ superclass.declared_secrets + own
49
+ else
50
+ own
51
+ end
52
+ end
53
+
54
+ # Returns sensitive argument names from schema metadata and declared secrets.
55
+ #
56
+ # A parameter is treated as sensitive when its schema property includes:
57
+ # sensitive: true
58
+ def sensitive_parameters
59
+ schema = parameters || {}
60
+ properties = schema[:properties] || schema["properties"] || {}
61
+ schema_sensitive = Set.new
62
+ if properties.is_a?(Hash)
63
+ properties.each do |name, definition|
64
+ next unless definition.is_a?(Hash)
65
+
66
+ sensitive = definition[:sensitive]
67
+ sensitive = definition["sensitive"] if sensitive.nil?
68
+ schema_sensitive << name.to_sym if sensitive
69
+ end
70
+ end
71
+
72
+ secret_names = (declared_secrets || []).map { |secret| secret[:name] }
73
+ schema_sensitive | Set.new(secret_names)
74
+ end
75
+
76
+ # Declares that this tool requires confirmation before execution.
77
+ def requires_confirmation(val = true)
78
+ @requires_confirmation = val
79
+ end
80
+
81
+ def requires_confirmation?
82
+ return @requires_confirmation unless @requires_confirmation.nil?
83
+
84
+ return superclass.requires_confirmation? if superclass.respond_to?(:requires_confirmation?)
85
+
86
+ false
87
+ end
88
+
89
+ # Declares a timeout in seconds for tool execution.
90
+ def timeout(seconds = nil)
91
+ unless seconds.nil?
92
+ @timeout = seconds
93
+ else
94
+ @timeout
95
+ end
96
+ end
97
+
98
+ # Declares that tool calls are idempotent and can be cached by key.
99
+ def idempotent(val = true)
100
+ @idempotent = val
101
+ end
102
+
103
+ def idempotent?
104
+ return @idempotent unless @idempotent.nil?
105
+
106
+ return superclass.idempotent? if superclass.respond_to?(:idempotent?)
107
+
108
+ false
109
+ end
110
+
111
+ # Declares which params form the idempotency key.
112
+ def idempotency_key(*params)
113
+ @idempotency_key_params = params.flatten.map(&:to_sym)
114
+ end
115
+
116
+ def idempotency_key_params
117
+ return @idempotency_key_params if instance_variable_defined?(:@idempotency_key_params)
118
+
119
+ return superclass.idempotency_key_params if superclass.respond_to?(:idempotency_key_params)
120
+
121
+ nil
122
+ end
123
+
124
+ # Declares cache TTL (seconds) for idempotent results.
125
+ def idempotency_ttl(seconds = nil)
126
+ unless seconds.nil?
127
+ @idempotency_ttl = seconds
128
+ end
129
+ @idempotency_ttl
130
+ end
131
+
132
+ def idempotency_ttl_value
133
+ ttl = idempotency_ttl
134
+ return ttl unless ttl.nil?
135
+
136
+ if superclass.respond_to?(:idempotency_ttl_value)
137
+ return superclass.idempotency_ttl_value
138
+ end
139
+
140
+ default_ttl = Spurline.config.idempotency_default_ttl
141
+ return default_ttl unless default_ttl.nil?
142
+
143
+ Spurline::Tools::Idempotency::Ledger::DEFAULT_TTL
144
+ end
145
+
146
+ # Declares a custom key lambda taking the final args hash.
147
+ def idempotency_key_fn(fn = nil)
148
+ @idempotency_key_fn = fn if fn
149
+ @idempotency_key_fn
150
+ end
151
+
152
+ # Declares that tool expects injected _scope keyword argument.
153
+ def scoped(val = true)
154
+ @scoped = val
155
+ end
156
+
157
+ def scoped?
158
+ return @scoped unless @scoped.nil?
159
+
160
+ return superclass.scoped? if superclass.respond_to?(:scoped?)
161
+
162
+ false
163
+ end
164
+
165
+ # Validates arguments against the tool's parameter schema.
166
+ # Checks required properties and type mismatches.
167
+ # Returns true if valid, raises ConfigurationError if invalid.
168
+ def validate_arguments!(args)
169
+ schema = parameters
170
+ return true if schema.empty?
171
+
172
+ properties = schema[:properties] || schema["properties"] || {}
173
+ required = schema[:required] || schema["required"] || []
174
+
175
+ # Check required properties
176
+ required.each do |prop|
177
+ prop_sym = prop.to_sym
178
+ unless args.key?(prop_sym) || args.key?(prop.to_s)
179
+ raise Spurline::ConfigurationError,
180
+ "Tool '#{tool_name}' missing required parameter '#{prop}'. " \
181
+ "Required parameters: #{required.join(", ")}."
182
+ end
183
+ end
184
+
185
+ true
186
+ end
187
+ end
188
+
189
+ def name
190
+ self.class.tool_name
191
+ end
192
+
193
+ def call(**_args)
194
+ raise NotImplementedError,
195
+ "#{self.class.name} must implement #call. Tools are leaf nodes (ADR-003) — " \
196
+ "they receive arguments and return a result. Use a Spurline::Skill for composition."
197
+ end
198
+
199
+ # Returns the tool schema for the LLM adapter.
200
+ def to_schema
201
+ {
202
+ name: name,
203
+ description: self.class.description,
204
+ input_schema: self.class.parameters,
205
+ }
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Spurline
7
+ module Tools
8
+ module Idempotency
9
+ # Computes idempotency keys from tool name and arguments.
10
+ class KeyComputer
11
+ # Computes a deterministic key for a tool call.
12
+ #
13
+ # @param tool_name [Symbol] Tool identifier
14
+ # @param args [Hash] Tool call arguments
15
+ # @param key_params [Array<Symbol>, nil] Specific params to include (nil = all)
16
+ # @param key_fn [Proc, nil] Custom key computation lambda
17
+ # @return [String] Deterministic key string "tool_name:hash"
18
+ #
19
+ # Key computation:
20
+ # 1. If key_fn provided: "#{tool_name}:#{key_fn.call(args)}"
21
+ # 2. If key_params provided: SHA256 of only those params
22
+ # 3. Default: SHA256 of all args (canonical JSON with sorted keys)
23
+ def self.compute(tool_name:, args:, key_params: nil, key_fn: nil)
24
+ prefix = tool_name.to_s
25
+
26
+ hash = if key_fn
27
+ key_fn.call(args).to_s
28
+ elsif key_params
29
+ canonical_hash(args.slice(*key_params))
30
+ else
31
+ canonical_hash(args)
32
+ end
33
+
34
+ "#{prefix}:#{hash}"
35
+ end
36
+
37
+ # Produces a deterministic hash of arguments.
38
+ # Sorts keys recursively for canonical representation.
39
+ def self.canonical_hash(args)
40
+ json = JSON.generate(canonicalize(args))
41
+ Digest::SHA256.hexdigest(json)
42
+ end
43
+
44
+ # Recursively sorts hash keys for deterministic serialization.
45
+ def self.canonicalize(obj)
46
+ case obj
47
+ when Hash
48
+ obj.sort_by { |k, _| k.to_s }.map { |k, v| [k.to_s, canonicalize(v)] }.to_h
49
+ when Array
50
+ obj.map { |v| canonicalize(v) }
51
+ else
52
+ obj
53
+ end
54
+ end
55
+ end
56
+
57
+ # Session-scoped cache for idempotent tool results.
58
+ # Wraps a plain hash (from session.metadata[:idempotency_ledger]).
59
+ class Ledger
60
+ DEFAULT_TTL = 86_400 # 24 hours
61
+
62
+ # @param store [Hash] The backing hash (from session.metadata)
63
+ def initialize(store)
64
+ @store = store
65
+ @store[:entries] ||= {}
66
+ end
67
+
68
+ # Returns true if key exists and is not expired.
69
+ #
70
+ # @param key [String] Idempotency key
71
+ # @param ttl [Integer] TTL in seconds
72
+ # @return [Boolean]
73
+ def cached?(key, ttl: DEFAULT_TTL)
74
+ entry = @store[:entries][key]
75
+ return false unless entry
76
+
77
+ age = Time.now.to_f - entry[:timestamp]
78
+ if age > ttl
79
+ @store[:entries].delete(key) # Lazy cleanup
80
+ false
81
+ else
82
+ true
83
+ end
84
+ end
85
+
86
+ # Returns the cached result, or nil if not cached/expired.
87
+ #
88
+ # @param key [String] Idempotency key
89
+ # @param ttl [Integer] TTL in seconds
90
+ # @return [String, nil]
91
+ def fetch(key, ttl: DEFAULT_TTL)
92
+ return nil unless cached?(key, ttl: ttl)
93
+
94
+ @store[:entries][key][:result]
95
+ end
96
+
97
+ # Stores a result with timestamp.
98
+ #
99
+ # @param key [String] Idempotency key
100
+ # @param result [String] Serialized tool result
101
+ # @param args_hash [String] Hash of the arguments (for conflict detection)
102
+ # @param ttl [Integer] TTL in seconds (stored for reference)
103
+ def store!(key, result:, args_hash:, ttl: DEFAULT_TTL)
104
+ @store[:entries][key] = {
105
+ result: result,
106
+ args_hash: args_hash,
107
+ timestamp: Time.now.to_f,
108
+ ttl: ttl,
109
+ }
110
+ end
111
+
112
+ # Returns true if key exists with different arguments.
113
+ # Same key + different args = bug in calling code.
114
+ #
115
+ # @param key [String] Idempotency key
116
+ # @param args_hash [String] Hash of current arguments
117
+ # @return [Boolean]
118
+ def conflict?(key, args_hash)
119
+ entry = @store[:entries][key]
120
+ return false unless entry
121
+
122
+ entry[:args_hash] != args_hash
123
+ end
124
+
125
+ # Returns age of cached entry in milliseconds, or nil.
126
+ def cache_age_ms(key)
127
+ entry = @store[:entries][key]
128
+ return nil unless entry
129
+
130
+ ((Time.now.to_f - entry[:timestamp]) * 1000).round
131
+ end
132
+
133
+ # Removes all expired entries.
134
+ def cleanup_expired!(default_ttl: DEFAULT_TTL)
135
+ now = Time.now.to_f
136
+ @store[:entries].delete_if do |_key, entry|
137
+ ttl = entry[:ttl] || default_ttl
138
+ (now - entry[:timestamp]) > ttl
139
+ end
140
+ end
141
+
142
+ # Empties the entire ledger.
143
+ def clear!
144
+ @store[:entries] = {}
145
+ end
146
+
147
+ # Returns the number of entries.
148
+ def size
149
+ @store[:entries].size
150
+ end
151
+
152
+ # Returns true if the ledger has no entries.
153
+ def empty?
154
+ @store[:entries].empty?
155
+ end
156
+ end
157
+
158
+ # Per-tool idempotency configuration.
159
+ # Built from class-level declarations or DSL config.
160
+ class Config
161
+ attr_reader :enabled, :key_params, :ttl, :key_fn
162
+
163
+ # @param enabled [Boolean] Whether idempotency is enabled for this tool
164
+ # @param key_params [Array<Symbol>, nil] Which params form the key (nil = all)
165
+ # @param ttl [Integer] TTL in seconds
166
+ # @param key_fn [Proc, nil] Custom key computation lambda
167
+ def initialize(enabled: false, key_params: nil, ttl: Ledger::DEFAULT_TTL, key_fn: nil)
168
+ @enabled = enabled
169
+ @key_params = key_params
170
+ @ttl = ttl
171
+ @key_fn = key_fn
172
+ freeze
173
+ end
174
+
175
+ def enabled?
176
+ @enabled
177
+ end
178
+
179
+ # Builds a Config from a tool class's class-level declarations.
180
+ #
181
+ # @param tool_class [Class] Tool class with idempotency declarations
182
+ # @return [Config]
183
+ def self.from_tool_class(tool_class)
184
+ new(
185
+ enabled: tool_class.respond_to?(:idempotent?) && tool_class.idempotent?,
186
+ key_params: tool_class.respond_to?(:idempotency_key_params) ? tool_class.idempotency_key_params : nil,
187
+ ttl: tool_class.respond_to?(:idempotency_ttl_value) ? tool_class.idempotency_ttl_value : Ledger::DEFAULT_TTL,
188
+ key_fn: tool_class.respond_to?(:idempotency_key_fn) ? tool_class.idempotency_key_fn : nil,
189
+ )
190
+ end
191
+
192
+ # Builds a Config from DSL options hash.
193
+ # DSL wins on conflict with class declarations.
194
+ #
195
+ # @param dsl_options [Hash] { idempotent: true, idempotency_key: :tx_id, idempotency_ttl: 3600 }
196
+ # @param tool_class [Class, nil] Tool class for fallback values
197
+ # @return [Config]
198
+ def self.from_dsl(dsl_options, tool_class: nil)
199
+ base = tool_class ? from_tool_class(tool_class) : new
200
+
201
+ new(
202
+ enabled: dsl_options.fetch(:idempotent, base.enabled),
203
+ key_params: normalize_key_params(dsl_options.fetch(:idempotency_key, base.key_params)),
204
+ ttl: dsl_options.fetch(:idempotency_ttl, base.ttl),
205
+ key_fn: dsl_options.fetch(:idempotency_key_fn, base.key_fn),
206
+ )
207
+ end
208
+
209
+ def self.normalize_key_params(value)
210
+ case value
211
+ when Symbol then [value]
212
+ when Array then value
213
+ when nil then nil
214
+ else [value]
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Spurline
6
+ module Tools
7
+ # Loads tool permissions from a YAML file.
8
+ # Expected format:
9
+ #
10
+ # tools:
11
+ # web_search:
12
+ # denied: false
13
+ # allowed_users:
14
+ # - admin
15
+ # - researcher
16
+ # requires_confirmation: true
17
+ # dangerous_tool:
18
+ # denied: true
19
+ #
20
+ # Returns a hash keyed by tool name symbols.
21
+ class Permissions
22
+ def self.load_file(path)
23
+ return {} unless path && File.exist?(path)
24
+
25
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
26
+ tools = raw["tools"] || raw[:tools] || {}
27
+
28
+ tools.each_with_object({}) do |(name, config), result|
29
+ result[name.to_sym] = symbolize_config(config)
30
+ end
31
+ end
32
+
33
+ def self.symbolize_config(config)
34
+ return {} unless config.is_a?(Hash)
35
+
36
+ config.each_with_object({}) do |(key, value), result|
37
+ result[key.to_sym] = value
38
+ end
39
+ end
40
+
41
+ private_class_method :symbolize_config
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Tools
5
+ # Global registry of available tools. Tools register themselves here,
6
+ # either directly or via spur gem auto-registration.
7
+ class Registry
8
+ def initialize
9
+ @tools = {}
10
+ end
11
+
12
+ def register(name, tool_class)
13
+ name = name.to_sym
14
+ @tools[name] = tool_class
15
+ end
16
+
17
+ def fetch(name)
18
+ name = name.to_sym
19
+ @tools.fetch(name) do
20
+ raise Spurline::ToolNotFoundError,
21
+ "Tool '#{name}' is not registered. Ensure its spur gem is installed " \
22
+ "and required, or register it manually with Spurline::Tools::Registry#register."
23
+ end
24
+ end
25
+
26
+ def registered?(name)
27
+ @tools.key?(name.to_sym)
28
+ end
29
+
30
+ def all
31
+ @tools.dup
32
+ end
33
+
34
+ def names
35
+ @tools.keys
36
+ end
37
+
38
+ def clear!
39
+ @tools.clear
40
+ end
41
+ end
42
+ end
43
+ end