spurline-test 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.
- checksums.yaml +7 -0
- data/lib/spurline/adapters/base.rb +17 -0
- data/lib/spurline/adapters/claude.rb +208 -0
- data/lib/spurline/adapters/open_ai.rb +213 -0
- data/lib/spurline/adapters/registry.rb +33 -0
- data/lib/spurline/adapters/scheduler/base.rb +15 -0
- data/lib/spurline/adapters/scheduler/sync.rb +15 -0
- data/lib/spurline/adapters/stub_adapter.rb +54 -0
- data/lib/spurline/agent.rb +433 -0
- data/lib/spurline/audit/log.rb +156 -0
- data/lib/spurline/audit/secret_filter.rb +121 -0
- data/lib/spurline/base.rb +130 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
- data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
- data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
- data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
- data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
- data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
- data/lib/spurline/cartographer/repo_profile.rb +140 -0
- data/lib/spurline/cartographer/runner.rb +88 -0
- data/lib/spurline/cartographer.rb +6 -0
- data/lib/spurline/channels/base.rb +41 -0
- data/lib/spurline/channels/event.rb +136 -0
- data/lib/spurline/channels/github.rb +205 -0
- data/lib/spurline/channels/router.rb +103 -0
- data/lib/spurline/cli/check.rb +88 -0
- data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
- data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
- data/lib/spurline/cli/checks/base.rb +35 -0
- data/lib/spurline/cli/checks/credentials.rb +43 -0
- data/lib/spurline/cli/checks/permissions.rb +22 -0
- data/lib/spurline/cli/checks/project_structure.rb +48 -0
- data/lib/spurline/cli/checks/session_store.rb +97 -0
- data/lib/spurline/cli/console.rb +73 -0
- data/lib/spurline/cli/credentials.rb +181 -0
- data/lib/spurline/cli/generators/agent.rb +123 -0
- data/lib/spurline/cli/generators/migration.rb +62 -0
- data/lib/spurline/cli/generators/project.rb +331 -0
- data/lib/spurline/cli/generators/tool.rb +98 -0
- data/lib/spurline/cli/router.rb +121 -0
- data/lib/spurline/configuration.rb +23 -0
- data/lib/spurline/dsl/guardrails.rb +108 -0
- data/lib/spurline/dsl/hooks.rb +51 -0
- data/lib/spurline/dsl/memory.rb +39 -0
- data/lib/spurline/dsl/model.rb +23 -0
- data/lib/spurline/dsl/persona.rb +74 -0
- data/lib/spurline/dsl/suspend_until.rb +53 -0
- data/lib/spurline/dsl/tools.rb +176 -0
- data/lib/spurline/errors.rb +109 -0
- data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
- data/lib/spurline/lifecycle/runner.rb +456 -0
- data/lib/spurline/lifecycle/states.rb +47 -0
- data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/base.rb +17 -0
- data/lib/spurline/memory/embedder/open_ai.rb +70 -0
- data/lib/spurline/memory/episode.rb +56 -0
- data/lib/spurline/memory/episodic_store.rb +147 -0
- data/lib/spurline/memory/long_term/base.rb +22 -0
- data/lib/spurline/memory/long_term/postgres.rb +106 -0
- data/lib/spurline/memory/manager.rb +147 -0
- data/lib/spurline/memory/short_term.rb +57 -0
- data/lib/spurline/orchestration/agent_spawner.rb +151 -0
- data/lib/spurline/orchestration/judge.rb +109 -0
- data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
- data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
- data/lib/spurline/orchestration/ledger.rb +339 -0
- data/lib/spurline/orchestration/merge_queue.rb +133 -0
- data/lib/spurline/orchestration/permission_intersection.rb +151 -0
- data/lib/spurline/orchestration/task_envelope.rb +201 -0
- data/lib/spurline/persona/base.rb +42 -0
- data/lib/spurline/persona/registry.rb +42 -0
- data/lib/spurline/secrets/resolver.rb +65 -0
- data/lib/spurline/secrets/vault.rb +42 -0
- data/lib/spurline/security/content.rb +76 -0
- data/lib/spurline/security/context_pipeline.rb +58 -0
- data/lib/spurline/security/gates/base.rb +36 -0
- data/lib/spurline/security/gates/operator_config.rb +22 -0
- data/lib/spurline/security/gates/system_prompt.rb +23 -0
- data/lib/spurline/security/gates/tool_result.rb +23 -0
- data/lib/spurline/security/gates/user_input.rb +22 -0
- data/lib/spurline/security/injection_scanner.rb +109 -0
- data/lib/spurline/security/pii_filter.rb +104 -0
- data/lib/spurline/session/resumption.rb +36 -0
- data/lib/spurline/session/serializer.rb +169 -0
- data/lib/spurline/session/session.rb +154 -0
- data/lib/spurline/session/store/base.rb +27 -0
- data/lib/spurline/session/store/memory.rb +45 -0
- data/lib/spurline/session/store/postgres.rb +123 -0
- data/lib/spurline/session/store/sqlite.rb +139 -0
- data/lib/spurline/session/suspension.rb +93 -0
- data/lib/spurline/session/turn.rb +98 -0
- data/lib/spurline/spur.rb +213 -0
- data/lib/spurline/streaming/buffer.rb +77 -0
- data/lib/spurline/streaming/chunk.rb +62 -0
- data/lib/spurline/streaming/stream_enumerator.rb +29 -0
- data/lib/spurline/testing.rb +245 -0
- data/lib/spurline/toolkit.rb +110 -0
- data/lib/spurline/tools/base.rb +209 -0
- data/lib/spurline/tools/idempotency.rb +220 -0
- data/lib/spurline/tools/permissions.rb +44 -0
- data/lib/spurline/tools/registry.rb +43 -0
- data/lib/spurline/tools/runner.rb +255 -0
- data/lib/spurline/tools/scope.rb +309 -0
- data/lib/spurline/tools/toolkit_registry.rb +63 -0
- data/lib/spurline/version.rb +5 -0
- data/lib/spurline.rb +56 -0
- metadata +160 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module CLI
|
|
7
|
+
module Generators
|
|
8
|
+
# Generates a new Spurline agent project scaffold.
|
|
9
|
+
# Usage: spur new my_agent
|
|
10
|
+
class Project
|
|
11
|
+
attr_reader :name, :root
|
|
12
|
+
|
|
13
|
+
def initialize(name:)
|
|
14
|
+
@name = name
|
|
15
|
+
@root = File.expand_path(name)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def generate!
|
|
19
|
+
if Dir.exist?(root)
|
|
20
|
+
$stderr.puts "Directory '#{name}' already exists."
|
|
21
|
+
exit 1
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
puts "Creating new Spurline project: #{name}"
|
|
25
|
+
|
|
26
|
+
create_directories!
|
|
27
|
+
create_gemfile!
|
|
28
|
+
create_rakefile!
|
|
29
|
+
create_initializer!
|
|
30
|
+
create_application_agent!
|
|
31
|
+
create_example_agent!
|
|
32
|
+
create_spec_helper!
|
|
33
|
+
create_example_agent_spec!
|
|
34
|
+
create_permissions!
|
|
35
|
+
create_gitignore!
|
|
36
|
+
create_ruby_version!
|
|
37
|
+
create_env_example!
|
|
38
|
+
create_readme!
|
|
39
|
+
|
|
40
|
+
puts ""
|
|
41
|
+
puts "Project '#{name}' created successfully!"
|
|
42
|
+
puts ""
|
|
43
|
+
puts "Next steps:"
|
|
44
|
+
puts " cd #{name}"
|
|
45
|
+
puts " bundle install"
|
|
46
|
+
puts " bundle exec rspec"
|
|
47
|
+
puts ""
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def create_directories!
|
|
53
|
+
dirs = %w[
|
|
54
|
+
app/agents
|
|
55
|
+
app/tools
|
|
56
|
+
config
|
|
57
|
+
spec
|
|
58
|
+
spec/agents
|
|
59
|
+
spec/tools
|
|
60
|
+
]
|
|
61
|
+
dirs.each { |dir| FileUtils.mkdir_p(File.join(root, dir)) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def create_gemfile!
|
|
65
|
+
write_file("Gemfile", <<~RUBY)
|
|
66
|
+
# frozen_string_literal: true
|
|
67
|
+
|
|
68
|
+
source "https://rubygems.org"
|
|
69
|
+
|
|
70
|
+
gem "spurline-core"
|
|
71
|
+
|
|
72
|
+
# Uncomment to add bundled spurs:
|
|
73
|
+
# gem "spurline-web-search"
|
|
74
|
+
|
|
75
|
+
group :development, :test do
|
|
76
|
+
gem "rspec"
|
|
77
|
+
# gem "webmock" # Useful for testing tools that make HTTP calls
|
|
78
|
+
end
|
|
79
|
+
RUBY
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def create_rakefile!
|
|
83
|
+
write_file("Rakefile", <<~RUBY)
|
|
84
|
+
# frozen_string_literal: true
|
|
85
|
+
|
|
86
|
+
require "rspec/core/rake_task"
|
|
87
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
88
|
+
task default: :spec
|
|
89
|
+
RUBY
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def create_initializer!
|
|
93
|
+
write_file("config/spurline.rb", <<~RUBY)
|
|
94
|
+
# frozen_string_literal: true
|
|
95
|
+
|
|
96
|
+
require "spurline"
|
|
97
|
+
|
|
98
|
+
Spurline.configure do |config|
|
|
99
|
+
config.default_model = :claude_sonnet
|
|
100
|
+
config.session_store = :memory
|
|
101
|
+
config.permissions_file = "config/permissions.yml"
|
|
102
|
+
|
|
103
|
+
# Durable sessions (survives process restart):
|
|
104
|
+
# config.session_store = :sqlite
|
|
105
|
+
# config.session_store_path = "tmp/spurline_sessions.db"
|
|
106
|
+
#
|
|
107
|
+
# PostgreSQL sessions (for team deployments):
|
|
108
|
+
# config.session_store = :postgres
|
|
109
|
+
# config.session_store_postgres_url = "postgresql://localhost/my_app_development"
|
|
110
|
+
end
|
|
111
|
+
RUBY
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def create_application_agent!
|
|
115
|
+
write_file("app/agents/application_agent.rb", <<~RUBY)
|
|
116
|
+
# frozen_string_literal: true
|
|
117
|
+
|
|
118
|
+
require "spurline"
|
|
119
|
+
|
|
120
|
+
# The shared base class for all agents in this project.
|
|
121
|
+
# Configure defaults here -- individual agents inherit and override.
|
|
122
|
+
class ApplicationAgent < Spurline::Agent
|
|
123
|
+
use_model :claude_sonnet
|
|
124
|
+
|
|
125
|
+
guardrails do
|
|
126
|
+
max_tool_calls 10
|
|
127
|
+
injection_filter :strict
|
|
128
|
+
pii_filter :off
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Uncomment to add a default persona with date injection:
|
|
132
|
+
# persona(:default) do
|
|
133
|
+
# system_prompt "You are a helpful assistant."
|
|
134
|
+
# inject_date true
|
|
135
|
+
# end
|
|
136
|
+
|
|
137
|
+
# Uncomment to add lifecycle hooks:
|
|
138
|
+
# on_start { |session| puts "Session \#{session.id} started" }
|
|
139
|
+
# on_finish { |session| puts "Session \#{session.id} finished" }
|
|
140
|
+
# on_error { |error| $stderr.puts "Error: \#{error.message}" }
|
|
141
|
+
|
|
142
|
+
# Uncomment for memory window customization:
|
|
143
|
+
# memory :short_term, window: 20
|
|
144
|
+
end
|
|
145
|
+
RUBY
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def create_example_agent!
|
|
149
|
+
write_file("app/agents/assistant_agent.rb", <<~RUBY)
|
|
150
|
+
# frozen_string_literal: true
|
|
151
|
+
|
|
152
|
+
require_relative "application_agent"
|
|
153
|
+
|
|
154
|
+
class AssistantAgent < ApplicationAgent
|
|
155
|
+
persona(:default) do
|
|
156
|
+
system_prompt "You are a helpful assistant for the #{classify(name)} project."
|
|
157
|
+
inject_date true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Uncomment to register tools:
|
|
161
|
+
# tools :example_tool
|
|
162
|
+
|
|
163
|
+
# Uncomment to override guardrails from ApplicationAgent:
|
|
164
|
+
# guardrails do
|
|
165
|
+
# max_tool_calls 5
|
|
166
|
+
# end
|
|
167
|
+
end
|
|
168
|
+
RUBY
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def create_spec_helper!
|
|
172
|
+
write_file("spec/spec_helper.rb", <<~RUBY)
|
|
173
|
+
# frozen_string_literal: true
|
|
174
|
+
|
|
175
|
+
require_relative "../config/spurline"
|
|
176
|
+
require "spurline/testing"
|
|
177
|
+
|
|
178
|
+
# Load application files
|
|
179
|
+
Dir[File.join(__dir__, "..", "app", "**", "*.rb")].sort.each { |f| require f }
|
|
180
|
+
|
|
181
|
+
RSpec.configure do |config|
|
|
182
|
+
config.expect_with :rspec do |expectations|
|
|
183
|
+
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
config.mock_with :rspec do |mocks|
|
|
187
|
+
mocks.verify_partial_doubles = true
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
config.order = :random
|
|
191
|
+
Kernel.srand config.seed
|
|
192
|
+
end
|
|
193
|
+
RUBY
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def create_example_agent_spec!
|
|
197
|
+
write_file("spec/agents/assistant_agent_spec.rb", <<~RUBY)
|
|
198
|
+
# frozen_string_literal: true
|
|
199
|
+
|
|
200
|
+
RSpec.describe AssistantAgent do
|
|
201
|
+
let(:agent) do
|
|
202
|
+
described_class.new.tap do |a|
|
|
203
|
+
a.use_stub_adapter(responses: [stub_text("Hello!")])
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
describe "#run" do
|
|
208
|
+
it "streams a response" do
|
|
209
|
+
chunks = []
|
|
210
|
+
agent.run("Say hello") { |chunk| chunks << chunk }
|
|
211
|
+
|
|
212
|
+
text = chunks.select(&:text?).map(&:text).join
|
|
213
|
+
expect(text).to eq("Hello!")
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
RUBY
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def create_permissions!
|
|
221
|
+
write_file("config/permissions.yml", <<~YAML)
|
|
222
|
+
# Tool permission configuration.
|
|
223
|
+
# See: https://github.com/dylanwilcox/spurline
|
|
224
|
+
#
|
|
225
|
+
# tools:
|
|
226
|
+
# dangerous_tool:
|
|
227
|
+
# denied: true
|
|
228
|
+
# sensitive_tool:
|
|
229
|
+
# requires_confirmation: true
|
|
230
|
+
# allowed_users:
|
|
231
|
+
# - admin
|
|
232
|
+
tools: {}
|
|
233
|
+
YAML
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def create_gitignore!
|
|
237
|
+
write_file(".gitignore", <<~TEXT)
|
|
238
|
+
/.bundle/
|
|
239
|
+
/vendor/bundle
|
|
240
|
+
/tmp/
|
|
241
|
+
/log/
|
|
242
|
+
config/master.key
|
|
243
|
+
tmp/spurline_sessions.db
|
|
244
|
+
*.gem
|
|
245
|
+
.env
|
|
246
|
+
Gemfile.lock
|
|
247
|
+
TEXT
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def create_ruby_version!
|
|
251
|
+
write_file(".ruby-version", "3.4.5\n")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def create_env_example!
|
|
255
|
+
write_file(".env.example", <<~TEXT)
|
|
256
|
+
# Spurline environment variables.
|
|
257
|
+
# Copy this file to .env and fill in your values.
|
|
258
|
+
# Never commit .env to version control.
|
|
259
|
+
|
|
260
|
+
ANTHROPIC_API_KEY=your_key_here
|
|
261
|
+
|
|
262
|
+
# Uncomment for encrypted credentials support:
|
|
263
|
+
# SPURLINE_MASTER_KEY=your_32_byte_hex_key
|
|
264
|
+
TEXT
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def create_readme!
|
|
268
|
+
write_file("README.md", <<~MARKDOWN)
|
|
269
|
+
# #{classify(name)}
|
|
270
|
+
|
|
271
|
+
A [Spurline](https://github.com/dylanwilcox/spurline) agent project.
|
|
272
|
+
|
|
273
|
+
## Setup
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
bundle install
|
|
277
|
+
cp .env.example .env
|
|
278
|
+
# Edit .env with your ANTHROPIC_API_KEY
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Validate
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
bundle exec spur check
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Run Tests
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
bundle exec rspec
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Project Structure
|
|
294
|
+
|
|
295
|
+
```
|
|
296
|
+
app/
|
|
297
|
+
agents/ # Agent classes (inherit from ApplicationAgent)
|
|
298
|
+
tools/ # Tool classes (inherit from Spurline::Tools::Base)
|
|
299
|
+
config/
|
|
300
|
+
spurline.rb # Framework configuration
|
|
301
|
+
permissions.yml # Tool permission rules
|
|
302
|
+
spec/ # RSpec test files
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## Generators
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
spur generate agent researcher # Creates app/agents/researcher_agent.rb
|
|
309
|
+
spur generate tool web_scraper # Creates app/tools/web_scraper.rb + spec
|
|
310
|
+
```
|
|
311
|
+
MARKDOWN
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def write_file(relative_path, content)
|
|
315
|
+
path = File.join(root, relative_path)
|
|
316
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
317
|
+
File.write(path, content)
|
|
318
|
+
puts " create #{relative_path}"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def classify(str)
|
|
322
|
+
str.to_s
|
|
323
|
+
.gsub(/[-_]/, " ")
|
|
324
|
+
.split(" ")
|
|
325
|
+
.map(&:capitalize)
|
|
326
|
+
.join
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module CLI
|
|
7
|
+
module Generators
|
|
8
|
+
# Generates a new tool class file.
|
|
9
|
+
# Usage: spur generate tool web_scraper
|
|
10
|
+
class Tool
|
|
11
|
+
attr_reader :name
|
|
12
|
+
|
|
13
|
+
def initialize(name:)
|
|
14
|
+
@name = name.to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate!
|
|
18
|
+
path = File.join("app", "tools", "#{snake_name}.rb")
|
|
19
|
+
|
|
20
|
+
if File.exist?(path)
|
|
21
|
+
$stderr.puts "File already exists: #{path}"
|
|
22
|
+
exit 1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
26
|
+
File.write(path, tool_template)
|
|
27
|
+
puts " create #{path}"
|
|
28
|
+
|
|
29
|
+
spec_path = File.join("spec", "tools", "#{snake_name}_spec.rb")
|
|
30
|
+
FileUtils.mkdir_p(File.dirname(spec_path))
|
|
31
|
+
File.write(spec_path, spec_template)
|
|
32
|
+
puts " create #{spec_path}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def tool_template
|
|
38
|
+
<<~RUBY
|
|
39
|
+
# frozen_string_literal: true
|
|
40
|
+
|
|
41
|
+
class #{class_name} < Spurline::Tools::Base
|
|
42
|
+
tool_name :#{snake_name}
|
|
43
|
+
description "TODO: Describe what #{snake_name} does"
|
|
44
|
+
parameters({
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
input: { type: "string", description: "TODO: describe input" },
|
|
48
|
+
},
|
|
49
|
+
required: %w[input],
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
def call(input:)
|
|
53
|
+
# TODO: Implement #{snake_name}
|
|
54
|
+
raise NotImplementedError, "#{class_name}#call not yet implemented"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
RUBY
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def spec_template
|
|
61
|
+
<<~RUBY
|
|
62
|
+
# frozen_string_literal: true
|
|
63
|
+
|
|
64
|
+
require_relative "../../app/tools/#{snake_name}"
|
|
65
|
+
|
|
66
|
+
RSpec.describe #{class_name} do
|
|
67
|
+
let(:tool) { described_class.new }
|
|
68
|
+
|
|
69
|
+
describe "#call" do
|
|
70
|
+
it "executes the tool" do
|
|
71
|
+
# TODO: Write tests for #{snake_name}
|
|
72
|
+
pending "implement #{class_name}#call first"
|
|
73
|
+
result = tool.call(input: "test")
|
|
74
|
+
expect(result).not_to be_nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
RUBY
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def class_name
|
|
82
|
+
name.to_s
|
|
83
|
+
.gsub(/[-_]/, " ")
|
|
84
|
+
.split(" ")
|
|
85
|
+
.map(&:capitalize)
|
|
86
|
+
.join
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def snake_name
|
|
90
|
+
name.to_s
|
|
91
|
+
.gsub(/([a-z])([A-Z])/, '\1_\2')
|
|
92
|
+
.gsub(/[-\s]/, "_")
|
|
93
|
+
.downcase
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module CLI
|
|
5
|
+
# Routes CLI commands to the appropriate handler.
|
|
6
|
+
# Entry point: Router.run(ARGV)
|
|
7
|
+
class Router
|
|
8
|
+
COMMANDS = {
|
|
9
|
+
"new" => :handle_new,
|
|
10
|
+
"generate" => :handle_generate,
|
|
11
|
+
"check" => :handle_check,
|
|
12
|
+
"console" => :handle_console,
|
|
13
|
+
"credentials:edit" => :handle_credentials_edit,
|
|
14
|
+
"version" => :handle_version,
|
|
15
|
+
"help" => :handle_help,
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
GENERATE_SUBCOMMANDS = %w[agent tool migration].freeze
|
|
19
|
+
|
|
20
|
+
def self.run(args)
|
|
21
|
+
new(args).dispatch
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(args)
|
|
25
|
+
@args = args
|
|
26
|
+
@command = args.first
|
|
27
|
+
@rest = args[1..] || []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def dispatch
|
|
31
|
+
if @command.nil? || @command == "help" || @command == "--help" || @command == "-h"
|
|
32
|
+
handle_help
|
|
33
|
+
elsif @command == "version" || @command == "--version" || @command == "-v"
|
|
34
|
+
handle_version
|
|
35
|
+
elsif COMMANDS.key?(@command)
|
|
36
|
+
send(COMMANDS[@command])
|
|
37
|
+
else
|
|
38
|
+
$stderr.puts "Unknown command: #{@command}"
|
|
39
|
+
$stderr.puts "Run 'spur help' for available commands."
|
|
40
|
+
exit 1
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def handle_new
|
|
47
|
+
project_name = @rest.first
|
|
48
|
+
unless project_name
|
|
49
|
+
$stderr.puts "Usage: spur new <project_name>"
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Generators::Project.new(name: project_name).generate!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def handle_generate
|
|
57
|
+
subcommand = @rest.first
|
|
58
|
+
name = @rest[1]
|
|
59
|
+
|
|
60
|
+
unless subcommand && GENERATE_SUBCOMMANDS.include?(subcommand)
|
|
61
|
+
$stderr.puts "Usage: spur generate <#{GENERATE_SUBCOMMANDS.join("|")}> <name>"
|
|
62
|
+
exit 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
unless name
|
|
66
|
+
$stderr.puts "Usage: spur generate #{subcommand} <name>"
|
|
67
|
+
exit 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
case subcommand
|
|
71
|
+
when "agent"
|
|
72
|
+
Generators::Agent.new(name: name).generate!
|
|
73
|
+
when "tool"
|
|
74
|
+
Generators::Tool.new(name: name).generate!
|
|
75
|
+
when "migration"
|
|
76
|
+
Generators::Migration.new(name: name).generate!
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def handle_version
|
|
81
|
+
puts "spur #{Spurline::VERSION}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def handle_check
|
|
85
|
+
verbose = @rest.include?("--verbose") || @rest.include?("-v")
|
|
86
|
+
results = Check.new(project_root: Dir.pwd, verbose: verbose).run!
|
|
87
|
+
failures = results.count { |result| result.status == :fail }
|
|
88
|
+
exit(failures.positive? ? 1 : 0)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def handle_console
|
|
92
|
+
verbose = @rest.include?("--verbose") || @rest.include?("-v")
|
|
93
|
+
Console.new(project_root: Dir.pwd, verbose: verbose).start!
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def handle_credentials_edit
|
|
97
|
+
Credentials.new(project_root: Dir.pwd).edit!
|
|
98
|
+
puts "Saved encrypted credentials to config/credentials.enc.yml"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def handle_help
|
|
102
|
+
puts <<~HELP
|
|
103
|
+
spur — Spurline CLI
|
|
104
|
+
|
|
105
|
+
Commands:
|
|
106
|
+
spur new <project> Create a new Spurline agent project
|
|
107
|
+
spur generate agent <name> Generate a new agent class
|
|
108
|
+
spur generate tool <name> Generate a new tool class
|
|
109
|
+
spur generate migration <name> Generate a SQL migration (e.g. sessions)
|
|
110
|
+
spur check Validate project configuration
|
|
111
|
+
spur console Interactive REPL with project loaded
|
|
112
|
+
spur credentials:edit Edit encrypted credentials
|
|
113
|
+
spur version Show version
|
|
114
|
+
spur help Show this help
|
|
115
|
+
|
|
116
|
+
https://github.com/dylanwilcox/spurline
|
|
117
|
+
HELP
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-configurable"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
class Configuration
|
|
7
|
+
extend Dry::Configurable
|
|
8
|
+
|
|
9
|
+
setting :session_store, default: :memory
|
|
10
|
+
setting :session_store_path, default: "tmp/spurline_sessions.db"
|
|
11
|
+
setting :session_store_postgres_url, default: nil
|
|
12
|
+
setting :default_model, default: :claude_sonnet
|
|
13
|
+
setting :log_level, default: :info
|
|
14
|
+
setting :audit_mode, default: :full
|
|
15
|
+
setting :audit_max_entries, default: nil
|
|
16
|
+
setting :idempotency_default_ttl, default: 86_400
|
|
17
|
+
setting :permissions_file, default: "config/permissions.yml"
|
|
18
|
+
setting :brave_api_key, default: nil
|
|
19
|
+
setting :cartographer_exclude_patterns, default: %w[
|
|
20
|
+
.git node_modules vendor tmp log coverage
|
|
21
|
+
]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module DSL
|
|
5
|
+
# DSL for configuring security guardrails.
|
|
6
|
+
# Registers configuration at class load time — never executes behavior.
|
|
7
|
+
# Misconfiguration raises ConfigurationError at class load time.
|
|
8
|
+
module Guardrails
|
|
9
|
+
def self.included(base)
|
|
10
|
+
base.extend(ClassMethods)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module ClassMethods
|
|
14
|
+
def guardrails(&block)
|
|
15
|
+
@guardrail_config ||= GuardrailConfig.new
|
|
16
|
+
@guardrail_config.instance_eval(&block)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def guardrail_config
|
|
20
|
+
own = @guardrail_config
|
|
21
|
+
if own
|
|
22
|
+
own
|
|
23
|
+
elsif superclass.respond_to?(:guardrail_config)
|
|
24
|
+
superclass.guardrail_config
|
|
25
|
+
else
|
|
26
|
+
GuardrailConfig.new
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class GuardrailConfig
|
|
32
|
+
INJECTION_LEVELS = %i[strict moderate permissive].freeze
|
|
33
|
+
PII_MODES = %i[redact block warn off].freeze
|
|
34
|
+
AUDIT_MODES = %i[full errors_only off].freeze
|
|
35
|
+
|
|
36
|
+
attr_reader :settings
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@settings = {
|
|
40
|
+
injection_filter: :strict,
|
|
41
|
+
pii_filter: :off,
|
|
42
|
+
max_tool_calls: 10,
|
|
43
|
+
max_turns: 50,
|
|
44
|
+
audit_max_entries: nil,
|
|
45
|
+
denied_domains: [],
|
|
46
|
+
audit: :full,
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def injection_filter(level)
|
|
51
|
+
validate_inclusion!(:injection_filter, level, INJECTION_LEVELS)
|
|
52
|
+
@settings[:injection_filter] = level
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def pii_filter(mode)
|
|
56
|
+
validate_inclusion!(:pii_filter, mode, PII_MODES)
|
|
57
|
+
@settings[:pii_filter] = mode
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def max_tool_calls(n)
|
|
61
|
+
validate_positive_integer!(:max_tool_calls, n)
|
|
62
|
+
@settings[:max_tool_calls] = n
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def max_turns(n)
|
|
66
|
+
validate_positive_integer!(:max_turns, n)
|
|
67
|
+
@settings[:max_turns] = n
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def audit_max_entries(n)
|
|
71
|
+
validate_positive_integer!(:audit_max_entries, n)
|
|
72
|
+
@settings[:audit_max_entries] = n
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def denied_domains(domains)
|
|
76
|
+
@settings[:denied_domains] = Array(domains)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def audit(mode)
|
|
80
|
+
validate_inclusion!(:audit, mode, AUDIT_MODES)
|
|
81
|
+
@settings[:audit] = mode
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def to_h
|
|
85
|
+
@settings.dup
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def validate_inclusion!(name, value, valid_values)
|
|
91
|
+
return if valid_values.include?(value)
|
|
92
|
+
|
|
93
|
+
raise Spurline::ConfigurationError,
|
|
94
|
+
"Invalid guardrail value for #{name}: #{value.inspect}. " \
|
|
95
|
+
"Must be one of: #{valid_values.map(&:inspect).join(", ")}."
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def validate_positive_integer!(name, value)
|
|
99
|
+
return if value.is_a?(Integer) && value.positive?
|
|
100
|
+
|
|
101
|
+
raise Spurline::ConfigurationError,
|
|
102
|
+
"Invalid guardrail value for #{name}: #{value.inspect}. " \
|
|
103
|
+
"Must be a positive integer."
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|