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,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