spurline-docs 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +7 -0
  2. data/lib/spurline/adapters/base.rb +17 -0
  3. data/lib/spurline/adapters/claude.rb +208 -0
  4. data/lib/spurline/adapters/open_ai.rb +213 -0
  5. data/lib/spurline/adapters/registry.rb +33 -0
  6. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  7. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  8. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  9. data/lib/spurline/agent.rb +433 -0
  10. data/lib/spurline/audit/log.rb +156 -0
  11. data/lib/spurline/audit/secret_filter.rb +121 -0
  12. data/lib/spurline/base.rb +130 -0
  13. data/lib/spurline/cartographer/analyzer.rb +71 -0
  14. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  15. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  16. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  17. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  18. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  19. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  20. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  21. data/lib/spurline/cartographer/runner.rb +88 -0
  22. data/lib/spurline/cartographer.rb +6 -0
  23. data/lib/spurline/channels/base.rb +41 -0
  24. data/lib/spurline/channels/event.rb +136 -0
  25. data/lib/spurline/channels/github.rb +205 -0
  26. data/lib/spurline/channels/router.rb +103 -0
  27. data/lib/spurline/cli/check.rb +88 -0
  28. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  29. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  30. data/lib/spurline/cli/checks/base.rb +35 -0
  31. data/lib/spurline/cli/checks/credentials.rb +43 -0
  32. data/lib/spurline/cli/checks/permissions.rb +22 -0
  33. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  34. data/lib/spurline/cli/checks/session_store.rb +97 -0
  35. data/lib/spurline/cli/console.rb +73 -0
  36. data/lib/spurline/cli/credentials.rb +181 -0
  37. data/lib/spurline/cli/generators/agent.rb +123 -0
  38. data/lib/spurline/cli/generators/migration.rb +62 -0
  39. data/lib/spurline/cli/generators/project.rb +331 -0
  40. data/lib/spurline/cli/generators/tool.rb +98 -0
  41. data/lib/spurline/cli/router.rb +121 -0
  42. data/lib/spurline/configuration.rb +23 -0
  43. data/lib/spurline/dsl/guardrails.rb +108 -0
  44. data/lib/spurline/dsl/hooks.rb +51 -0
  45. data/lib/spurline/dsl/memory.rb +39 -0
  46. data/lib/spurline/dsl/model.rb +23 -0
  47. data/lib/spurline/dsl/persona.rb +74 -0
  48. data/lib/spurline/dsl/suspend_until.rb +53 -0
  49. data/lib/spurline/dsl/tools.rb +176 -0
  50. data/lib/spurline/errors.rb +109 -0
  51. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  52. data/lib/spurline/lifecycle/runner.rb +456 -0
  53. data/lib/spurline/lifecycle/states.rb +47 -0
  54. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  55. data/lib/spurline/memory/context_assembler.rb +100 -0
  56. data/lib/spurline/memory/embedder/base.rb +17 -0
  57. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  58. data/lib/spurline/memory/episode.rb +56 -0
  59. data/lib/spurline/memory/episodic_store.rb +147 -0
  60. data/lib/spurline/memory/long_term/base.rb +22 -0
  61. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  62. data/lib/spurline/memory/manager.rb +147 -0
  63. data/lib/spurline/memory/short_term.rb +57 -0
  64. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  65. data/lib/spurline/orchestration/judge.rb +109 -0
  66. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  67. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  68. data/lib/spurline/orchestration/ledger.rb +339 -0
  69. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  70. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  71. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  72. data/lib/spurline/persona/base.rb +42 -0
  73. data/lib/spurline/persona/registry.rb +42 -0
  74. data/lib/spurline/secrets/resolver.rb +65 -0
  75. data/lib/spurline/secrets/vault.rb +42 -0
  76. data/lib/spurline/security/content.rb +76 -0
  77. data/lib/spurline/security/context_pipeline.rb +58 -0
  78. data/lib/spurline/security/gates/base.rb +36 -0
  79. data/lib/spurline/security/gates/operator_config.rb +22 -0
  80. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  81. data/lib/spurline/security/gates/tool_result.rb +23 -0
  82. data/lib/spurline/security/gates/user_input.rb +22 -0
  83. data/lib/spurline/security/injection_scanner.rb +109 -0
  84. data/lib/spurline/security/pii_filter.rb +104 -0
  85. data/lib/spurline/session/resumption.rb +36 -0
  86. data/lib/spurline/session/serializer.rb +169 -0
  87. data/lib/spurline/session/session.rb +154 -0
  88. data/lib/spurline/session/store/base.rb +27 -0
  89. data/lib/spurline/session/store/memory.rb +45 -0
  90. data/lib/spurline/session/store/postgres.rb +123 -0
  91. data/lib/spurline/session/store/sqlite.rb +139 -0
  92. data/lib/spurline/session/suspension.rb +93 -0
  93. data/lib/spurline/session/turn.rb +98 -0
  94. data/lib/spurline/spur.rb +213 -0
  95. data/lib/spurline/streaming/buffer.rb +77 -0
  96. data/lib/spurline/streaming/chunk.rb +62 -0
  97. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  98. data/lib/spurline/testing.rb +245 -0
  99. data/lib/spurline/toolkit.rb +110 -0
  100. data/lib/spurline/tools/base.rb +209 -0
  101. data/lib/spurline/tools/idempotency.rb +220 -0
  102. data/lib/spurline/tools/permissions.rb +44 -0
  103. data/lib/spurline/tools/registry.rb +43 -0
  104. data/lib/spurline/tools/runner.rb +255 -0
  105. data/lib/spurline/tools/scope.rb +309 -0
  106. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  107. data/lib/spurline/version.rb +5 -0
  108. data/lib/spurline.rb +56 -0
  109. metadata +160 -0
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module CLI
5
+ module Checks
6
+ CheckResult = Data.define(:status, :name, :message)
7
+
8
+ class Base
9
+ def initialize(project_root:)
10
+ @project_root = File.expand_path(project_root)
11
+ end
12
+
13
+ def run
14
+ raise NotImplementedError
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :project_root
20
+
21
+ def pass(name, message: nil)
22
+ CheckResult.new(status: :pass, name: name, message: message)
23
+ end
24
+
25
+ def fail(name, message:)
26
+ CheckResult.new(status: :fail, name: name, message: message)
27
+ end
28
+
29
+ def warn(name, message:)
30
+ CheckResult.new(status: :warn, name: name, message: message)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module CLI
5
+ module Checks
6
+ class Credentials < Base
7
+ WARNING_MESSAGE = "ANTHROPIC_API_KEY not set; agents using :claude_sonnet will fail at runtime"
8
+
9
+ def run
10
+ env_key = ENV.fetch("ANTHROPIC_API_KEY", nil)
11
+ return [pass(:credentials)] if present_key?(env_key)
12
+
13
+ credentials_path = File.join(project_root, "config", "credentials.enc.yml")
14
+ unless File.file?(credentials_path)
15
+ return [warn(:credentials, message: WARNING_MESSAGE)]
16
+ end
17
+
18
+ manager = Spurline::CLI::Credentials.new(project_root: project_root)
19
+ unless manager.master_key
20
+ return [warn(:credentials, message: "#{WARNING_MESSAGE}; master key not found")]
21
+ end
22
+
23
+ credentials = manager.read
24
+ if present_key?(credentials["anthropic_api_key"])
25
+ [pass(:credentials)]
26
+ else
27
+ [warn(:credentials, message: "#{WARNING_MESSAGE}; encrypted anthropic_api_key is blank")]
28
+ end
29
+ rescue Spurline::CredentialsMissingKeyError => e
30
+ [warn(:credentials, message: "#{WARNING_MESSAGE}; #{e.message}")]
31
+ rescue StandardError => e
32
+ [fail(:credentials, message: "#{e.class}: #{e.message}")]
33
+ end
34
+
35
+ private
36
+
37
+ def present_key?(value)
38
+ value && !value.strip.empty?
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module CLI
5
+ module Checks
6
+ class Permissions < Base
7
+ def run
8
+ path = File.join(project_root, "config", "permissions.yml")
9
+
10
+ unless File.file?(path)
11
+ return [fail(:permissions, message: "Missing config/permissions.yml")]
12
+ end
13
+
14
+ Spurline::Tools::Permissions.load_file(path)
15
+ [pass(:permissions)]
16
+ rescue StandardError => e
17
+ [fail(:permissions, message: "#{e.class}: #{e.message}")]
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module CLI
5
+ module Checks
6
+ class ProjectStructure < Base
7
+ REQUIRED_DIRECTORIES = %w[app/agents app/tools config].freeze
8
+ REQUIRED_FILES = %w[Gemfile].freeze
9
+ RECOMMENDED_FILES = %w[config/spurline.rb config/permissions.yml .env.example].freeze
10
+
11
+ def run
12
+ missing = []
13
+ results = []
14
+
15
+ REQUIRED_DIRECTORIES.each do |directory|
16
+ path = File.join(project_root, directory)
17
+ missing << directory unless Dir.exist?(path)
18
+ end
19
+
20
+ REQUIRED_FILES.each do |file|
21
+ path = File.join(project_root, file)
22
+ missing << file unless File.file?(path)
23
+ end
24
+
25
+ if missing.empty?
26
+ results << pass(:project_structure)
27
+ else
28
+ results << fail(
29
+ :project_structure,
30
+ message: "Missing required paths: #{missing.join(", ")}. " \
31
+ "Run 'spur new <project>' to create a project scaffold."
32
+ )
33
+ return results
34
+ end
35
+
36
+ RECOMMENDED_FILES.each do |file|
37
+ path = File.join(project_root, file)
38
+ next if File.file?(path)
39
+
40
+ results << warn(:"missing_#{file.tr('/.', '_')}", message: "Recommended file missing: #{file}")
41
+ end
42
+
43
+ results
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module CLI
5
+ module Checks
6
+ class SessionStore < Base
7
+ def run
8
+ load_framework!
9
+
10
+ case Spurline.config.session_store
11
+ when nil, :memory
12
+ [pass(:session_store)]
13
+ when :sqlite
14
+ validate_sqlite_store
15
+ when :postgres
16
+ validate_postgres_store
17
+ else
18
+ [pass(:session_store, message: "Custom session store configured; skipped built-in validation")]
19
+ end
20
+ rescue StandardError => e
21
+ [fail(:session_store, message: "#{e.class}: #{e.message}")]
22
+ end
23
+
24
+ private
25
+
26
+ def load_framework!
27
+ initializer = File.join(project_root, "config", "spurline.rb")
28
+ if File.file?(initializer)
29
+ require initializer
30
+ else
31
+ require "spurline"
32
+ end
33
+ end
34
+
35
+ def validate_sqlite_store
36
+ require "sqlite3"
37
+
38
+ path = Spurline.config.session_store_path
39
+ return [pass(:session_store)] if path == ":memory:"
40
+
41
+ expanded = File.expand_path(path, project_root)
42
+ parent = File.dirname(expanded)
43
+
44
+ if writable_path?(parent)
45
+ [pass(:session_store)]
46
+ else
47
+ [fail(:session_store, message: "Session store directory is not writable: #{parent}")]
48
+ end
49
+ rescue LoadError
50
+ [fail(:session_store, message: "sqlite3 gem is not available for :sqlite session store")]
51
+ end
52
+
53
+ def validate_postgres_store
54
+ url = Spurline.config.session_store_postgres_url
55
+ return [fail(:session_store, message: "session_store_postgres_url is not configured")] unless url && !url.strip.empty?
56
+
57
+ require "pg"
58
+
59
+ conn = PG.connect(url)
60
+ conn.exec("SELECT 1")
61
+ conn.close
62
+ [pass(:session_store)]
63
+ rescue LoadError
64
+ [fail(:session_store, message: "pg gem is not available for :postgres session store")]
65
+ rescue StandardError => e
66
+ if defined?(PG::Error) && e.is_a?(PG::Error)
67
+ [fail(:session_store, message: "Cannot connect to PostgreSQL: #{e.message}")]
68
+ else
69
+ raise
70
+ end
71
+ end
72
+
73
+ def writable_path?(path)
74
+ if Dir.exist?(path)
75
+ return File.writable?(path)
76
+ end
77
+
78
+ nearest_existing_ancestor(path).then do |ancestor|
79
+ ancestor && File.writable?(ancestor)
80
+ end
81
+ end
82
+
83
+ def nearest_existing_ancestor(path)
84
+ current = File.expand_path(path)
85
+ loop do
86
+ return current if Dir.exist?(current)
87
+
88
+ parent = File.dirname(current)
89
+ return nil if parent == current
90
+
91
+ current = parent
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module CLI
5
+ class Console
6
+ def initialize(project_root:, verbose: false)
7
+ @project_root = File.expand_path(project_root)
8
+ @verbose = verbose
9
+ end
10
+
11
+ def start!
12
+ ensure_project!
13
+
14
+ begin
15
+ load_project!
16
+ rescue StandardError => e
17
+ $stderr.puts "Project load error: #{e.class}: #{e.message}"
18
+ end
19
+
20
+ run_check! if verbose
21
+ start_repl!
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :project_root, :verbose
27
+
28
+ def ensure_project!
29
+ agents_dir = File.join(project_root, "app", "agents")
30
+ return if Dir.exist?(agents_dir)
31
+
32
+ $stderr.puts "No app/agents directory found. Run this command from a Spurline project root."
33
+ exit 1
34
+ end
35
+
36
+ def load_project!
37
+ initializer = File.join(project_root, "config", "spurline.rb")
38
+ if File.file?(initializer)
39
+ require initializer
40
+ else
41
+ require "spurline"
42
+ end
43
+
44
+ app_files.each { |file| require file }
45
+ end
46
+
47
+ def app_files
48
+ files = Dir[File.join(project_root, "app", "**", "*.rb")]
49
+ files.sort_by do |path|
50
+ [File.basename(path) == "application_agent.rb" ? 0 : 1, path]
51
+ end
52
+ end
53
+
54
+ def run_check!
55
+ Check.new(project_root: project_root).run!
56
+ rescue StandardError => e
57
+ $stderr.puts "Check error: #{e.class}: #{e.message}"
58
+ end
59
+
60
+ def start_repl!
61
+ require "irb"
62
+
63
+ puts "Spurline console v#{Spurline::VERSION}"
64
+ puts "Type 'exit' to quit."
65
+ original_argv = ARGV.dup
66
+ ARGV.replace([])
67
+ Dir.chdir(project_root) { IRB.start }
68
+ ensure
69
+ ARGV.replace(original_argv) if original_argv
70
+ end
71
+ end
72
+ end
73
+ end
@@ -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,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