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,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module Channels
|
|
7
|
+
# Central dispatcher for channel events. Accepts raw payloads, identifies
|
|
8
|
+
# the correct channel, calls route, and optionally resumes suspended sessions.
|
|
9
|
+
#
|
|
10
|
+
# The router is transport-agnostic -- it processes parsed payloads, not HTTP
|
|
11
|
+
# requests. Webhook endpoints (Rack middleware, Rails controllers) are the
|
|
12
|
+
# caller's responsibility.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# store = Spurline::Session::Store::Memory.new
|
|
16
|
+
# github = Spurline::Channels::GitHub.new(store: store)
|
|
17
|
+
# router = Spurline::Channels::Router.new(store: store, channels: [github])
|
|
18
|
+
#
|
|
19
|
+
# event = router.dispatch(channel_name: :github, payload: webhook_body, headers: headers)
|
|
20
|
+
# if event&.routed?
|
|
21
|
+
# agent = MyAgent.new(session_id: event.session_id)
|
|
22
|
+
# agent.resume { |chunk| ... }
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
class Router
|
|
26
|
+
attr_reader :store
|
|
27
|
+
|
|
28
|
+
def initialize(store:, channels: [])
|
|
29
|
+
@store = store
|
|
30
|
+
@channels = {}
|
|
31
|
+
channels.each { |ch| register(ch) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Registers a channel adapter.
|
|
35
|
+
def register(channel)
|
|
36
|
+
unless channel.respond_to?(:channel_name) && channel.respond_to?(:route)
|
|
37
|
+
raise ArgumentError,
|
|
38
|
+
"Channel must implement #channel_name and #route. Got #{channel.class.name}."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@channels[channel.channel_name.to_sym] = channel
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns all registered channel names.
|
|
45
|
+
def channel_names
|
|
46
|
+
@channels.keys
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns a registered channel by name, or nil.
|
|
50
|
+
def channel_for(name)
|
|
51
|
+
@channels[name.to_sym]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Dispatches a payload to the named channel and returns the resulting Event.
|
|
55
|
+
#
|
|
56
|
+
# If the event maps to a suspended session, the router calls
|
|
57
|
+
# Suspension.resume! to transition the session back to :running.
|
|
58
|
+
# The caller is then responsible for instantiating the agent and
|
|
59
|
+
# calling agent.resume.
|
|
60
|
+
#
|
|
61
|
+
# @param channel_name [Symbol] The channel to dispatch to
|
|
62
|
+
# @param payload [Hash] The parsed event payload
|
|
63
|
+
# @param headers [Hash] Optional HTTP headers
|
|
64
|
+
# @return [Spurline::Channels::Event, nil]
|
|
65
|
+
# ASYNC-READY:
|
|
66
|
+
def dispatch(channel_name:, payload:, headers: {})
|
|
67
|
+
channel = @channels[channel_name.to_sym]
|
|
68
|
+
return nil unless channel
|
|
69
|
+
|
|
70
|
+
event = channel.route(payload, headers: headers)
|
|
71
|
+
return nil unless event
|
|
72
|
+
|
|
73
|
+
resume_if_suspended!(event) if event.routed?
|
|
74
|
+
|
|
75
|
+
event
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Wraps an event's payload as a Content object via Gates::ToolResult.
|
|
79
|
+
# Use this when feeding the event payload into the context pipeline.
|
|
80
|
+
def wrap_payload(event)
|
|
81
|
+
text = event.payload.is_a?(Hash) ? JSON.generate(event.payload) : event.payload.to_s
|
|
82
|
+
Security::Gates::ToolResult.wrap(
|
|
83
|
+
text,
|
|
84
|
+
tool_name: "channel:#{event.channel}"
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def resume_if_suspended!(event)
|
|
91
|
+
session = @store.load(event.session_id)
|
|
92
|
+
return unless session
|
|
93
|
+
return unless session.state == :suspended
|
|
94
|
+
|
|
95
|
+
Session::Suspension.resume!(session)
|
|
96
|
+
rescue Spurline::InvalidResumeError
|
|
97
|
+
# Session is not actually suspended -- the channel's routing may be stale.
|
|
98
|
+
# Swallow the error; the caller can inspect the event and decide.
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module CLI
|
|
5
|
+
class Check
|
|
6
|
+
CHECKERS = [
|
|
7
|
+
Checks::ProjectStructure,
|
|
8
|
+
Checks::Permissions,
|
|
9
|
+
Checks::AgentLoadability,
|
|
10
|
+
Checks::AdapterResolution,
|
|
11
|
+
Checks::Credentials,
|
|
12
|
+
Checks::SessionStore,
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(project_root:, verbose: false)
|
|
16
|
+
@project_root = File.expand_path(project_root)
|
|
17
|
+
@verbose = verbose
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run!
|
|
21
|
+
results = run_checks
|
|
22
|
+
print_report(results)
|
|
23
|
+
results
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :project_root, :verbose
|
|
29
|
+
|
|
30
|
+
def run_checks
|
|
31
|
+
CHECKERS.flat_map do |checker_class|
|
|
32
|
+
checker_class.new(project_root: project_root).run
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
[Checks::CheckResult.new(
|
|
35
|
+
status: :fail,
|
|
36
|
+
name: checker_name(checker_class),
|
|
37
|
+
message: "#{e.class}: #{e.message}"
|
|
38
|
+
)]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def print_report(results)
|
|
43
|
+
puts "spur check"
|
|
44
|
+
puts
|
|
45
|
+
|
|
46
|
+
results.each do |result|
|
|
47
|
+
label = status_label(result.status)
|
|
48
|
+
line = " #{label.ljust(5)} #{result.name}"
|
|
49
|
+
if show_message?(result) && result.message && !result.message.empty?
|
|
50
|
+
line << " - #{result.message}"
|
|
51
|
+
end
|
|
52
|
+
puts line
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
puts
|
|
56
|
+
passes = results.count { |result| result.status == :pass }
|
|
57
|
+
failures = results.count { |result| result.status == :fail }
|
|
58
|
+
warnings = results.count { |result| result.status == :warn }
|
|
59
|
+
|
|
60
|
+
puts "#{passes} passed, #{failures} failed, #{warnings} #{warnings == 1 ? "warning" : "warnings"}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def show_message?(result)
|
|
64
|
+
verbose || result.status != :pass
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def status_label(status)
|
|
68
|
+
case status
|
|
69
|
+
when :pass
|
|
70
|
+
"ok"
|
|
71
|
+
when :warn
|
|
72
|
+
"WARN"
|
|
73
|
+
when :fail
|
|
74
|
+
"FAIL"
|
|
75
|
+
else
|
|
76
|
+
status.to_s.upcase
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def checker_name(checker_class)
|
|
81
|
+
checker_class.name.split("::").last
|
|
82
|
+
.gsub(/([a-z])([A-Z])/, '\1_\2')
|
|
83
|
+
.downcase
|
|
84
|
+
.to_sym
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
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,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module CLI
|
|
5
|
+
module Checks
|
|
6
|
+
class AdapterResolution < Base
|
|
7
|
+
def run
|
|
8
|
+
load_framework!
|
|
9
|
+
files = agent_files
|
|
10
|
+
|
|
11
|
+
if files.empty?
|
|
12
|
+
return [fail(:adapter_resolution, message: "No agent files found under app/agents")]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
files.each { |file| require file }
|
|
16
|
+
agents = resolve_agent_classes
|
|
17
|
+
|
|
18
|
+
if agents.empty?
|
|
19
|
+
return [fail(:adapter_resolution, message: "No Spurline::Agent subclasses found in app/agents")]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
unresolved = []
|
|
23
|
+
|
|
24
|
+
agents.each do |agent_class|
|
|
25
|
+
model_name = agent_class.model_config && agent_class.model_config[:name]
|
|
26
|
+
if model_name.nil?
|
|
27
|
+
unresolved << "#{agent_class.name} has no model configuration"
|
|
28
|
+
next
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
begin
|
|
32
|
+
agent_class.adapter_registry.resolve(model_name)
|
|
33
|
+
rescue Spurline::AdapterNotFoundError => e
|
|
34
|
+
unresolved << "#{agent_class.name}: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if unresolved.empty?
|
|
39
|
+
[pass(:adapter_resolution)]
|
|
40
|
+
else
|
|
41
|
+
[fail(:adapter_resolution, message: unresolved.join("; "))]
|
|
42
|
+
end
|
|
43
|
+
rescue LoadError, NameError, SyntaxError => e
|
|
44
|
+
[fail(:adapter_resolution, message: "#{e.class}: #{e.message}")]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def load_framework!
|
|
50
|
+
initializer = File.join(project_root, "config", "spurline.rb")
|
|
51
|
+
if File.file?(initializer)
|
|
52
|
+
require initializer
|
|
53
|
+
else
|
|
54
|
+
require "spurline"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def agent_files
|
|
59
|
+
files = Dir[File.join(project_root, "app", "agents", "**", "*.rb")]
|
|
60
|
+
files.sort_by do |path|
|
|
61
|
+
[File.basename(path) == "application_agent.rb" ? 0 : 1, path]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def resolve_agent_classes
|
|
66
|
+
agents_root = File.join(project_root, "app", "agents")
|
|
67
|
+
|
|
68
|
+
ObjectSpace.each_object(Class).select do |klass|
|
|
69
|
+
next false unless klass < Spurline::Agent
|
|
70
|
+
next false unless klass.name
|
|
71
|
+
|
|
72
|
+
source_path = Object.const_source_location(klass.name)&.first
|
|
73
|
+
next false unless source_path
|
|
74
|
+
|
|
75
|
+
File.expand_path(source_path).start_with?(File.expand_path(agents_root))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module CLI
|
|
5
|
+
module Checks
|
|
6
|
+
class AgentLoadability < Base
|
|
7
|
+
def run
|
|
8
|
+
load_framework!
|
|
9
|
+
files = agent_files
|
|
10
|
+
|
|
11
|
+
if files.empty?
|
|
12
|
+
return [fail(:agent_loadability, message: "No agent files found under app/agents")]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
files.each { |file| require file }
|
|
16
|
+
[pass(:agent_loadability)]
|
|
17
|
+
rescue LoadError, NameError, SyntaxError => e
|
|
18
|
+
[fail(:agent_loadability, message: "#{e.class}: #{e.message}")]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def load_framework!
|
|
24
|
+
initializer = File.join(project_root, "config", "spurline.rb")
|
|
25
|
+
if File.file?(initializer)
|
|
26
|
+
require initializer
|
|
27
|
+
else
|
|
28
|
+
require "spurline"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def agent_files
|
|
33
|
+
files = Dir[File.join(project_root, "app", "agents", "**", "*.rb")]
|
|
34
|
+
files.sort_by do |path|
|
|
35
|
+
[File.basename(path) == "application_agent.rb" ? 0 : 1, path]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -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
|