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.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +177 -0
  4. data/exe/spur +6 -0
  5. data/lib/CLAUDE.md +11 -0
  6. data/lib/spurline/CLAUDE.md +16 -0
  7. data/lib/spurline/adapters/CLAUDE.md +12 -0
  8. data/lib/spurline/adapters/base.rb +17 -0
  9. data/lib/spurline/adapters/claude.rb +208 -0
  10. data/lib/spurline/adapters/open_ai.rb +213 -0
  11. data/lib/spurline/adapters/registry.rb +33 -0
  12. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  13. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  14. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  15. data/lib/spurline/agent.rb +433 -0
  16. data/lib/spurline/audit/log.rb +156 -0
  17. data/lib/spurline/audit/secret_filter.rb +121 -0
  18. data/lib/spurline/base.rb +130 -0
  19. data/lib/spurline/cartographer/CLAUDE.md +12 -0
  20. data/lib/spurline/cartographer/analyzer.rb +71 -0
  21. data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
  22. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  23. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  24. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  25. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  26. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  27. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  28. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  29. data/lib/spurline/cartographer/runner.rb +88 -0
  30. data/lib/spurline/cartographer.rb +6 -0
  31. data/lib/spurline/channels/base.rb +41 -0
  32. data/lib/spurline/channels/event.rb +136 -0
  33. data/lib/spurline/channels/github.rb +205 -0
  34. data/lib/spurline/channels/router.rb +103 -0
  35. data/lib/spurline/cli/check.rb +88 -0
  36. data/lib/spurline/cli/checks/CLAUDE.md +11 -0
  37. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  38. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  39. data/lib/spurline/cli/checks/base.rb +35 -0
  40. data/lib/spurline/cli/checks/credentials.rb +43 -0
  41. data/lib/spurline/cli/checks/permissions.rb +22 -0
  42. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  43. data/lib/spurline/cli/checks/session_store.rb +97 -0
  44. data/lib/spurline/cli/console.rb +73 -0
  45. data/lib/spurline/cli/credentials.rb +181 -0
  46. data/lib/spurline/cli/generators/CLAUDE.md +11 -0
  47. data/lib/spurline/cli/generators/agent.rb +123 -0
  48. data/lib/spurline/cli/generators/migration.rb +62 -0
  49. data/lib/spurline/cli/generators/project.rb +331 -0
  50. data/lib/spurline/cli/generators/tool.rb +98 -0
  51. data/lib/spurline/cli/router.rb +121 -0
  52. data/lib/spurline/configuration.rb +23 -0
  53. data/lib/spurline/dsl/CLAUDE.md +11 -0
  54. data/lib/spurline/dsl/guardrails.rb +108 -0
  55. data/lib/spurline/dsl/hooks.rb +51 -0
  56. data/lib/spurline/dsl/memory.rb +39 -0
  57. data/lib/spurline/dsl/model.rb +23 -0
  58. data/lib/spurline/dsl/persona.rb +74 -0
  59. data/lib/spurline/dsl/suspend_until.rb +53 -0
  60. data/lib/spurline/dsl/tools.rb +176 -0
  61. data/lib/spurline/errors.rb +109 -0
  62. data/lib/spurline/lifecycle/CLAUDE.md +18 -0
  63. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  64. data/lib/spurline/lifecycle/runner.rb +456 -0
  65. data/lib/spurline/lifecycle/states.rb +47 -0
  66. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  67. data/lib/spurline/memory/CLAUDE.md +12 -0
  68. data/lib/spurline/memory/context_assembler.rb +100 -0
  69. data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
  70. data/lib/spurline/memory/embedder/base.rb +17 -0
  71. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  72. data/lib/spurline/memory/episode.rb +56 -0
  73. data/lib/spurline/memory/episodic_store.rb +147 -0
  74. data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
  75. data/lib/spurline/memory/long_term/base.rb +22 -0
  76. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  77. data/lib/spurline/memory/manager.rb +147 -0
  78. data/lib/spurline/memory/short_term.rb +57 -0
  79. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  80. data/lib/spurline/orchestration/judge.rb +109 -0
  81. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  82. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  83. data/lib/spurline/orchestration/ledger.rb +339 -0
  84. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  85. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  86. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  87. data/lib/spurline/persona/base.rb +42 -0
  88. data/lib/spurline/persona/registry.rb +42 -0
  89. data/lib/spurline/secrets/resolver.rb +65 -0
  90. data/lib/spurline/secrets/vault.rb +42 -0
  91. data/lib/spurline/security/content.rb +76 -0
  92. data/lib/spurline/security/context_pipeline.rb +58 -0
  93. data/lib/spurline/security/gates/base.rb +36 -0
  94. data/lib/spurline/security/gates/operator_config.rb +22 -0
  95. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  96. data/lib/spurline/security/gates/tool_result.rb +23 -0
  97. data/lib/spurline/security/gates/user_input.rb +22 -0
  98. data/lib/spurline/security/injection_scanner.rb +109 -0
  99. data/lib/spurline/security/pii_filter.rb +104 -0
  100. data/lib/spurline/session/CLAUDE.md +11 -0
  101. data/lib/spurline/session/resumption.rb +36 -0
  102. data/lib/spurline/session/serializer.rb +169 -0
  103. data/lib/spurline/session/session.rb +154 -0
  104. data/lib/spurline/session/store/CLAUDE.md +12 -0
  105. data/lib/spurline/session/store/base.rb +27 -0
  106. data/lib/spurline/session/store/memory.rb +45 -0
  107. data/lib/spurline/session/store/postgres.rb +123 -0
  108. data/lib/spurline/session/store/sqlite.rb +139 -0
  109. data/lib/spurline/session/suspension.rb +93 -0
  110. data/lib/spurline/session/turn.rb +98 -0
  111. data/lib/spurline/spur.rb +213 -0
  112. data/lib/spurline/streaming/CLAUDE.md +12 -0
  113. data/lib/spurline/streaming/buffer.rb +77 -0
  114. data/lib/spurline/streaming/chunk.rb +62 -0
  115. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  116. data/lib/spurline/testing.rb +245 -0
  117. data/lib/spurline/toolkit.rb +110 -0
  118. data/lib/spurline/tools/base.rb +209 -0
  119. data/lib/spurline/tools/idempotency.rb +220 -0
  120. data/lib/spurline/tools/permissions.rb +44 -0
  121. data/lib/spurline/tools/registry.rb +43 -0
  122. data/lib/spurline/tools/runner.rb +255 -0
  123. data/lib/spurline/tools/scope.rb +309 -0
  124. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  125. data/lib/spurline/version.rb +5 -0
  126. data/lib/spurline.rb +56 -0
  127. 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