spurline-core 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/LICENSE +21 -0
- data/README.md +177 -0
- data/exe/spur +6 -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 +333 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "shellwords"
|
|
7
|
+
require "tempfile"
|
|
8
|
+
require "yaml"
|
|
9
|
+
|
|
10
|
+
module Spurline
|
|
11
|
+
module CLI
|
|
12
|
+
class Credentials
|
|
13
|
+
DEFAULT_TEMPLATE = <<~YAML
|
|
14
|
+
# Spurline credentials - encrypted at rest.
|
|
15
|
+
# Edit with: spur credentials:edit
|
|
16
|
+
#
|
|
17
|
+
anthropic_api_key: ""
|
|
18
|
+
# brave_api_key: ""
|
|
19
|
+
YAML
|
|
20
|
+
|
|
21
|
+
IV_BYTES = 12
|
|
22
|
+
AUTH_TAG_BYTES = 16
|
|
23
|
+
KEY_BYTES = 32
|
|
24
|
+
|
|
25
|
+
def initialize(project_root:)
|
|
26
|
+
@project_root = File.expand_path(project_root)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def edit!
|
|
30
|
+
ensure_master_key!
|
|
31
|
+
plaintext = File.file?(credentials_path) ? decrypt_existing_credentials : project_template
|
|
32
|
+
|
|
33
|
+
Tempfile.create(["spurline-credentials", ".yml"]) do |file|
|
|
34
|
+
path = file.path
|
|
35
|
+
File.write(path, plaintext)
|
|
36
|
+
open_editor!(file.path)
|
|
37
|
+
edited = File.read(path)
|
|
38
|
+
parse_yaml(edited)
|
|
39
|
+
encrypt_and_write(edited)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def read
|
|
44
|
+
return {} unless File.file?(credentials_path)
|
|
45
|
+
|
|
46
|
+
parse_yaml(decrypt_existing_credentials)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def master_key
|
|
50
|
+
@master_key ||= resolve_master_key
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
attr_reader :project_root
|
|
56
|
+
|
|
57
|
+
def project_template
|
|
58
|
+
template_path = File.join(project_root, "config", "credentials.template.yml")
|
|
59
|
+
File.file?(template_path) ? File.read(template_path) : DEFAULT_TEMPLATE
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def credentials_path
|
|
63
|
+
File.join(project_root, "config", "credentials.enc.yml")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def master_key_path
|
|
67
|
+
File.join(project_root, "config", "master.key")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def ensure_master_key!
|
|
71
|
+
return if master_key
|
|
72
|
+
|
|
73
|
+
generate_master_key!
|
|
74
|
+
@master_key = resolve_master_key
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def generate_master_key!
|
|
78
|
+
FileUtils.mkdir_p(File.dirname(master_key_path))
|
|
79
|
+
hex_key = SecureRandom.random_bytes(KEY_BYTES).unpack1("H*")
|
|
80
|
+
File.write(master_key_path, "#{hex_key}\n")
|
|
81
|
+
File.chmod(0o600, master_key_path)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def resolve_master_key
|
|
85
|
+
hex = ENV.fetch("SPURLINE_MASTER_KEY", nil)
|
|
86
|
+
hex = read_master_key_file if hex.nil? || hex.strip.empty?
|
|
87
|
+
return nil if hex.nil? || hex.strip.empty?
|
|
88
|
+
|
|
89
|
+
decode_hex_key(hex)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def read_master_key_file
|
|
93
|
+
return nil unless File.file?(master_key_path)
|
|
94
|
+
|
|
95
|
+
File.read(master_key_path)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def decode_hex_key(hex)
|
|
99
|
+
stripped = hex.to_s.strip
|
|
100
|
+
unless stripped.match?(/\A[0-9a-fA-F]{#{KEY_BYTES * 2}}\z/)
|
|
101
|
+
raise Spurline::CredentialsMissingKeyError,
|
|
102
|
+
"Master key must be #{KEY_BYTES * 2} hex characters"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
[stripped].pack("H*")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def decrypt_existing_credentials
|
|
109
|
+
key = master_key
|
|
110
|
+
unless key
|
|
111
|
+
raise Spurline::CredentialsMissingKeyError,
|
|
112
|
+
"Missing master key. Set SPURLINE_MASTER_KEY or create config/master.key"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
payload = File.binread(credentials_path)
|
|
116
|
+
decrypt(payload, key)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def encrypt_and_write(plaintext)
|
|
120
|
+
key = master_key
|
|
121
|
+
unless key
|
|
122
|
+
raise Spurline::CredentialsMissingKeyError,
|
|
123
|
+
"Missing master key. Set SPURLINE_MASTER_KEY or create config/master.key"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
payload = encrypt(plaintext, key)
|
|
127
|
+
FileUtils.mkdir_p(File.dirname(credentials_path))
|
|
128
|
+
File.binwrite(credentials_path, payload)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def encrypt(plaintext, key)
|
|
132
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
133
|
+
cipher.encrypt
|
|
134
|
+
cipher.key = key
|
|
135
|
+
iv = SecureRandom.random_bytes(IV_BYTES)
|
|
136
|
+
cipher.iv = iv
|
|
137
|
+
ciphertext = cipher.update(plaintext) + cipher.final
|
|
138
|
+
tag = cipher.auth_tag
|
|
139
|
+
iv + tag + ciphertext
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def decrypt(payload, key)
|
|
143
|
+
unless payload && payload.bytesize >= (IV_BYTES + AUTH_TAG_BYTES)
|
|
144
|
+
raise Spurline::CredentialsDecryptionError, "Encrypted credentials file is invalid"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
iv = payload.byteslice(0, IV_BYTES)
|
|
148
|
+
tag = payload.byteslice(IV_BYTES, AUTH_TAG_BYTES)
|
|
149
|
+
ciphertext = payload.byteslice(IV_BYTES + AUTH_TAG_BYTES, payload.bytesize)
|
|
150
|
+
|
|
151
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
152
|
+
cipher.decrypt
|
|
153
|
+
cipher.key = key
|
|
154
|
+
cipher.iv = iv
|
|
155
|
+
cipher.auth_tag = tag
|
|
156
|
+
cipher.update(ciphertext) + cipher.final
|
|
157
|
+
rescue OpenSSL::Cipher::CipherError
|
|
158
|
+
raise Spurline::CredentialsDecryptionError, "Could not decrypt credentials with provided master key"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def open_editor!(path)
|
|
162
|
+
editor = ENV.fetch("EDITOR", "vi")
|
|
163
|
+
command = Shellwords.split(editor)
|
|
164
|
+
ok = system(*command, path)
|
|
165
|
+
return if ok
|
|
166
|
+
|
|
167
|
+
raise Spurline::ConfigurationError, "EDITOR command failed: #{editor}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def parse_yaml(content)
|
|
171
|
+
parsed = YAML.safe_load(content, aliases: false)
|
|
172
|
+
return {} if parsed.nil?
|
|
173
|
+
return parsed.transform_keys(&:to_s) if parsed.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
raise Spurline::ConfigurationError, "Credentials YAML must contain a mapping"
|
|
176
|
+
rescue Psych::SyntaxError => e
|
|
177
|
+
raise Spurline::ConfigurationError, "Invalid credentials YAML: #{e.message}"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<claude-mem-context>
|
|
2
|
+
# Recent Activity
|
|
3
|
+
|
|
4
|
+
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
|
5
|
+
|
|
6
|
+
### Feb 21, 2026
|
|
7
|
+
|
|
8
|
+
| ID | Time | T | Title | Read |
|
|
9
|
+
|----|------|---|-------|------|
|
|
10
|
+
| #3661 | 7:57 PM | 🔵 | Code quality review confirmed Plans 01-02 are production-ready with zero issues | ~791 |
|
|
11
|
+
</claude-mem-context>
|
|
@@ -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
|