spurline-dashboard 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/CLAUDE.md +11 -0
- data/lib/spurline/CLAUDE.md +16 -0
- data/lib/spurline/adapters/CLAUDE.md +12 -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/CLAUDE.md +12 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -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/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +18 -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/CLAUDE.md +12 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +12 -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/CLAUDE.md +12 -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 +218 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module CLI
|
|
7
|
+
module Generators
|
|
8
|
+
# Generates a new agent class file.
|
|
9
|
+
# Usage: spur generate agent research
|
|
10
|
+
class Agent
|
|
11
|
+
attr_reader :name
|
|
12
|
+
|
|
13
|
+
def initialize(name:)
|
|
14
|
+
@name = name.to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate!
|
|
18
|
+
verify_project_structure!
|
|
19
|
+
|
|
20
|
+
path = File.join("app", "agents", "#{snake_name}_agent.rb")
|
|
21
|
+
|
|
22
|
+
if File.exist?(path)
|
|
23
|
+
$stderr.puts "File already exists: #{path}"
|
|
24
|
+
exit 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
28
|
+
File.write(path, agent_template)
|
|
29
|
+
puts " create #{path}"
|
|
30
|
+
|
|
31
|
+
generate_spec_file!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def verify_project_structure!
|
|
37
|
+
unless Dir.exist?("app/agents")
|
|
38
|
+
$stderr.puts "No app/agents directory found. " \
|
|
39
|
+
"Run this from a Spurline project root, or run 'spur new' first."
|
|
40
|
+
exit 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
unless File.exist?(File.join("app", "agents", "application_agent.rb"))
|
|
44
|
+
$stderr.puts "No application_agent.rb found. Run 'spur new' first."
|
|
45
|
+
exit 1
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def agent_template
|
|
50
|
+
<<~RUBY
|
|
51
|
+
# frozen_string_literal: true
|
|
52
|
+
|
|
53
|
+
require_relative "application_agent"
|
|
54
|
+
|
|
55
|
+
class #{class_name}Agent < ApplicationAgent
|
|
56
|
+
persona(:default) do
|
|
57
|
+
system_prompt "You are a #{name.tr("_", " ")} agent."
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Uncomment to register tools:
|
|
61
|
+
# tools :example_tool
|
|
62
|
+
|
|
63
|
+
# Uncomment to override guardrails from ApplicationAgent:
|
|
64
|
+
# guardrails do
|
|
65
|
+
# max_tool_calls 5
|
|
66
|
+
# end
|
|
67
|
+
end
|
|
68
|
+
RUBY
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def generate_spec_file!
|
|
72
|
+
spec_path = File.join("spec", "agents", "#{snake_name}_agent_spec.rb")
|
|
73
|
+
if File.exist?(spec_path)
|
|
74
|
+
puts " skip #{spec_path} (already exists)"
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
FileUtils.mkdir_p(File.dirname(spec_path))
|
|
79
|
+
File.write(spec_path, spec_template)
|
|
80
|
+
puts " create #{spec_path}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def spec_template
|
|
84
|
+
<<~RUBY
|
|
85
|
+
# frozen_string_literal: true
|
|
86
|
+
|
|
87
|
+
RSpec.describe #{class_name}Agent do
|
|
88
|
+
let(:agent) do
|
|
89
|
+
described_class.new.tap do |a|
|
|
90
|
+
a.use_stub_adapter(responses: [stub_text("Test response")])
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
describe "#run" do
|
|
95
|
+
it "streams a response" do
|
|
96
|
+
chunks = []
|
|
97
|
+
agent.run("Test input") { |chunk| chunks << chunk }
|
|
98
|
+
text = chunks.select(&:text?).map(&:text).join
|
|
99
|
+
expect(text).not_to be_empty
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
RUBY
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def class_name
|
|
107
|
+
name.to_s
|
|
108
|
+
.gsub(/[-_]/, " ")
|
|
109
|
+
.split(" ")
|
|
110
|
+
.map(&:capitalize)
|
|
111
|
+
.join
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def snake_name
|
|
115
|
+
name.to_s
|
|
116
|
+
.gsub(/([a-z])([A-Z])/, '\1_\2')
|
|
117
|
+
.gsub(/[-\s]/, "_")
|
|
118
|
+
.downcase
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module CLI
|
|
7
|
+
module Generators
|
|
8
|
+
# Generates built-in SQL migrations.
|
|
9
|
+
# Usage: spur generate migration sessions
|
|
10
|
+
class Migration
|
|
11
|
+
MIGRATIONS = { "sessions" => :sessions_migration_sql }.freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :name
|
|
14
|
+
|
|
15
|
+
def initialize(name:)
|
|
16
|
+
@name = name.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def generate!
|
|
20
|
+
unless MIGRATIONS.key?(name)
|
|
21
|
+
$stderr.puts "Unknown migration: #{name}. Available: #{MIGRATIONS.keys.join(", ")}"
|
|
22
|
+
exit 1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if Dir.glob(File.join("db", "migrations", "*_create_spurline_#{name}.sql")).any?
|
|
26
|
+
$stderr.puts "Migration for #{name} already exists."
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
31
|
+
filename = "#{timestamp}_create_spurline_#{name}.sql"
|
|
32
|
+
path = File.join("db", "migrations", filename)
|
|
33
|
+
|
|
34
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
35
|
+
File.write(path, send(MIGRATIONS[name]))
|
|
36
|
+
puts " create #{path}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def sessions_migration_sql
|
|
42
|
+
<<~SQL
|
|
43
|
+
CREATE TABLE IF NOT EXISTS spurline_sessions (
|
|
44
|
+
id TEXT PRIMARY KEY,
|
|
45
|
+
state TEXT NOT NULL,
|
|
46
|
+
agent_class TEXT,
|
|
47
|
+
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
48
|
+
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
49
|
+
data JSONB NOT NULL
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_spurline_sessions_state
|
|
53
|
+
ON spurline_sessions(state);
|
|
54
|
+
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_spurline_sessions_agent_class
|
|
56
|
+
ON spurline_sessions(agent_class);
|
|
57
|
+
SQL
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -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
|