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.
- checksums.yaml +7 -0
- data/lib/spurline/adapters/base.rb +17 -0
- data/lib/spurline/adapters/claude.rb +208 -0
- data/lib/spurline/adapters/open_ai.rb +213 -0
- data/lib/spurline/adapters/registry.rb +33 -0
- data/lib/spurline/adapters/scheduler/base.rb +15 -0
- data/lib/spurline/adapters/scheduler/sync.rb +15 -0
- data/lib/spurline/adapters/stub_adapter.rb +54 -0
- data/lib/spurline/agent.rb +433 -0
- data/lib/spurline/audit/log.rb +156 -0
- data/lib/spurline/audit/secret_filter.rb +121 -0
- data/lib/spurline/base.rb +130 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
- data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
- data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
- data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
- data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
- data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
- data/lib/spurline/cartographer/repo_profile.rb +140 -0
- data/lib/spurline/cartographer/runner.rb +88 -0
- data/lib/spurline/cartographer.rb +6 -0
- data/lib/spurline/channels/base.rb +41 -0
- data/lib/spurline/channels/event.rb +136 -0
- data/lib/spurline/channels/github.rb +205 -0
- data/lib/spurline/channels/router.rb +103 -0
- data/lib/spurline/cli/check.rb +88 -0
- data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
- data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
- data/lib/spurline/cli/checks/base.rb +35 -0
- data/lib/spurline/cli/checks/credentials.rb +43 -0
- data/lib/spurline/cli/checks/permissions.rb +22 -0
- data/lib/spurline/cli/checks/project_structure.rb +48 -0
- data/lib/spurline/cli/checks/session_store.rb +97 -0
- data/lib/spurline/cli/console.rb +73 -0
- data/lib/spurline/cli/credentials.rb +181 -0
- data/lib/spurline/cli/generators/agent.rb +123 -0
- data/lib/spurline/cli/generators/migration.rb +62 -0
- data/lib/spurline/cli/generators/project.rb +331 -0
- data/lib/spurline/cli/generators/tool.rb +98 -0
- data/lib/spurline/cli/router.rb +121 -0
- data/lib/spurline/configuration.rb +23 -0
- data/lib/spurline/dsl/guardrails.rb +108 -0
- data/lib/spurline/dsl/hooks.rb +51 -0
- data/lib/spurline/dsl/memory.rb +39 -0
- data/lib/spurline/dsl/model.rb +23 -0
- data/lib/spurline/dsl/persona.rb +74 -0
- data/lib/spurline/dsl/suspend_until.rb +53 -0
- data/lib/spurline/dsl/tools.rb +176 -0
- data/lib/spurline/errors.rb +109 -0
- data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
- data/lib/spurline/lifecycle/runner.rb +456 -0
- data/lib/spurline/lifecycle/states.rb +47 -0
- data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/base.rb +17 -0
- data/lib/spurline/memory/embedder/open_ai.rb +70 -0
- data/lib/spurline/memory/episode.rb +56 -0
- data/lib/spurline/memory/episodic_store.rb +147 -0
- data/lib/spurline/memory/long_term/base.rb +22 -0
- data/lib/spurline/memory/long_term/postgres.rb +106 -0
- data/lib/spurline/memory/manager.rb +147 -0
- data/lib/spurline/memory/short_term.rb +57 -0
- data/lib/spurline/orchestration/agent_spawner.rb +151 -0
- data/lib/spurline/orchestration/judge.rb +109 -0
- data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
- data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
- data/lib/spurline/orchestration/ledger.rb +339 -0
- data/lib/spurline/orchestration/merge_queue.rb +133 -0
- data/lib/spurline/orchestration/permission_intersection.rb +151 -0
- data/lib/spurline/orchestration/task_envelope.rb +201 -0
- data/lib/spurline/persona/base.rb +42 -0
- data/lib/spurline/persona/registry.rb +42 -0
- data/lib/spurline/secrets/resolver.rb +65 -0
- data/lib/spurline/secrets/vault.rb +42 -0
- data/lib/spurline/security/content.rb +76 -0
- data/lib/spurline/security/context_pipeline.rb +58 -0
- data/lib/spurline/security/gates/base.rb +36 -0
- data/lib/spurline/security/gates/operator_config.rb +22 -0
- data/lib/spurline/security/gates/system_prompt.rb +23 -0
- data/lib/spurline/security/gates/tool_result.rb +23 -0
- data/lib/spurline/security/gates/user_input.rb +22 -0
- data/lib/spurline/security/injection_scanner.rb +109 -0
- data/lib/spurline/security/pii_filter.rb +104 -0
- data/lib/spurline/session/resumption.rb +36 -0
- data/lib/spurline/session/serializer.rb +169 -0
- data/lib/spurline/session/session.rb +154 -0
- data/lib/spurline/session/store/base.rb +27 -0
- data/lib/spurline/session/store/memory.rb +45 -0
- data/lib/spurline/session/store/postgres.rb +123 -0
- data/lib/spurline/session/store/sqlite.rb +139 -0
- data/lib/spurline/session/suspension.rb +93 -0
- data/lib/spurline/session/turn.rb +98 -0
- data/lib/spurline/spur.rb +213 -0
- data/lib/spurline/streaming/buffer.rb +77 -0
- data/lib/spurline/streaming/chunk.rb +62 -0
- data/lib/spurline/streaming/stream_enumerator.rb +29 -0
- data/lib/spurline/testing.rb +245 -0
- data/lib/spurline/toolkit.rb +110 -0
- data/lib/spurline/tools/base.rb +209 -0
- data/lib/spurline/tools/idempotency.rb +220 -0
- data/lib/spurline/tools/permissions.rb +44 -0
- data/lib/spurline/tools/registry.rb +43 -0
- data/lib/spurline/tools/runner.rb +255 -0
- data/lib/spurline/tools/scope.rb +309 -0
- data/lib/spurline/tools/toolkit_registry.rb +63 -0
- data/lib/spurline/version.rb +5 -0
- data/lib/spurline.rb +56 -0
- metadata +160 -0
|
@@ -0,0 +1,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
|