spurline-dashboard 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/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 +218 -0
|
@@ -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
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "shellwords"
|
|
7
|
+
require "tempfile"
|
|
8
|
+
require "yaml"
|
|
9
|
+
|
|
10
|
+
module Spurline
|
|
11
|
+
module CLI
|
|
12
|
+
class Credentials
|
|
13
|
+
DEFAULT_TEMPLATE = <<~YAML
|
|
14
|
+
# Spurline credentials - encrypted at rest.
|
|
15
|
+
# Edit with: spur credentials:edit
|
|
16
|
+
#
|
|
17
|
+
anthropic_api_key: ""
|
|
18
|
+
# brave_api_key: ""
|
|
19
|
+
YAML
|
|
20
|
+
|
|
21
|
+
IV_BYTES = 12
|
|
22
|
+
AUTH_TAG_BYTES = 16
|
|
23
|
+
KEY_BYTES = 32
|
|
24
|
+
|
|
25
|
+
def initialize(project_root:)
|
|
26
|
+
@project_root = File.expand_path(project_root)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def edit!
|
|
30
|
+
ensure_master_key!
|
|
31
|
+
plaintext = File.file?(credentials_path) ? decrypt_existing_credentials : project_template
|
|
32
|
+
|
|
33
|
+
Tempfile.create(["spurline-credentials", ".yml"]) do |file|
|
|
34
|
+
path = file.path
|
|
35
|
+
File.write(path, plaintext)
|
|
36
|
+
open_editor!(file.path)
|
|
37
|
+
edited = File.read(path)
|
|
38
|
+
parse_yaml(edited)
|
|
39
|
+
encrypt_and_write(edited)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def read
|
|
44
|
+
return {} unless File.file?(credentials_path)
|
|
45
|
+
|
|
46
|
+
parse_yaml(decrypt_existing_credentials)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def master_key
|
|
50
|
+
@master_key ||= resolve_master_key
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
attr_reader :project_root
|
|
56
|
+
|
|
57
|
+
def project_template
|
|
58
|
+
template_path = File.join(project_root, "config", "credentials.template.yml")
|
|
59
|
+
File.file?(template_path) ? File.read(template_path) : DEFAULT_TEMPLATE
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def credentials_path
|
|
63
|
+
File.join(project_root, "config", "credentials.enc.yml")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def master_key_path
|
|
67
|
+
File.join(project_root, "config", "master.key")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def ensure_master_key!
|
|
71
|
+
return if master_key
|
|
72
|
+
|
|
73
|
+
generate_master_key!
|
|
74
|
+
@master_key = resolve_master_key
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def generate_master_key!
|
|
78
|
+
FileUtils.mkdir_p(File.dirname(master_key_path))
|
|
79
|
+
hex_key = SecureRandom.random_bytes(KEY_BYTES).unpack1("H*")
|
|
80
|
+
File.write(master_key_path, "#{hex_key}\n")
|
|
81
|
+
File.chmod(0o600, master_key_path)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def resolve_master_key
|
|
85
|
+
hex = ENV.fetch("SPURLINE_MASTER_KEY", nil)
|
|
86
|
+
hex = read_master_key_file if hex.nil? || hex.strip.empty?
|
|
87
|
+
return nil if hex.nil? || hex.strip.empty?
|
|
88
|
+
|
|
89
|
+
decode_hex_key(hex)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def read_master_key_file
|
|
93
|
+
return nil unless File.file?(master_key_path)
|
|
94
|
+
|
|
95
|
+
File.read(master_key_path)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def decode_hex_key(hex)
|
|
99
|
+
stripped = hex.to_s.strip
|
|
100
|
+
unless stripped.match?(/\A[0-9a-fA-F]{#{KEY_BYTES * 2}}\z/)
|
|
101
|
+
raise Spurline::CredentialsMissingKeyError,
|
|
102
|
+
"Master key must be #{KEY_BYTES * 2} hex characters"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
[stripped].pack("H*")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def decrypt_existing_credentials
|
|
109
|
+
key = master_key
|
|
110
|
+
unless key
|
|
111
|
+
raise Spurline::CredentialsMissingKeyError,
|
|
112
|
+
"Missing master key. Set SPURLINE_MASTER_KEY or create config/master.key"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
payload = File.binread(credentials_path)
|
|
116
|
+
decrypt(payload, key)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def encrypt_and_write(plaintext)
|
|
120
|
+
key = master_key
|
|
121
|
+
unless key
|
|
122
|
+
raise Spurline::CredentialsMissingKeyError,
|
|
123
|
+
"Missing master key. Set SPURLINE_MASTER_KEY or create config/master.key"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
payload = encrypt(plaintext, key)
|
|
127
|
+
FileUtils.mkdir_p(File.dirname(credentials_path))
|
|
128
|
+
File.binwrite(credentials_path, payload)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def encrypt(plaintext, key)
|
|
132
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
133
|
+
cipher.encrypt
|
|
134
|
+
cipher.key = key
|
|
135
|
+
iv = SecureRandom.random_bytes(IV_BYTES)
|
|
136
|
+
cipher.iv = iv
|
|
137
|
+
ciphertext = cipher.update(plaintext) + cipher.final
|
|
138
|
+
tag = cipher.auth_tag
|
|
139
|
+
iv + tag + ciphertext
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def decrypt(payload, key)
|
|
143
|
+
unless payload && payload.bytesize >= (IV_BYTES + AUTH_TAG_BYTES)
|
|
144
|
+
raise Spurline::CredentialsDecryptionError, "Encrypted credentials file is invalid"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
iv = payload.byteslice(0, IV_BYTES)
|
|
148
|
+
tag = payload.byteslice(IV_BYTES, AUTH_TAG_BYTES)
|
|
149
|
+
ciphertext = payload.byteslice(IV_BYTES + AUTH_TAG_BYTES, payload.bytesize)
|
|
150
|
+
|
|
151
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
152
|
+
cipher.decrypt
|
|
153
|
+
cipher.key = key
|
|
154
|
+
cipher.iv = iv
|
|
155
|
+
cipher.auth_tag = tag
|
|
156
|
+
cipher.update(ciphertext) + cipher.final
|
|
157
|
+
rescue OpenSSL::Cipher::CipherError
|
|
158
|
+
raise Spurline::CredentialsDecryptionError, "Could not decrypt credentials with provided master key"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def open_editor!(path)
|
|
162
|
+
editor = ENV.fetch("EDITOR", "vi")
|
|
163
|
+
command = Shellwords.split(editor)
|
|
164
|
+
ok = system(*command, path)
|
|
165
|
+
return if ok
|
|
166
|
+
|
|
167
|
+
raise Spurline::ConfigurationError, "EDITOR command failed: #{editor}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def parse_yaml(content)
|
|
171
|
+
parsed = YAML.safe_load(content, aliases: false)
|
|
172
|
+
return {} if parsed.nil?
|
|
173
|
+
return parsed.transform_keys(&:to_s) if parsed.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
raise Spurline::ConfigurationError, "Credentials YAML must contain a mapping"
|
|
176
|
+
rescue Psych::SyntaxError => e
|
|
177
|
+
raise Spurline::ConfigurationError, "Invalid credentials YAML: #{e.message}"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<claude-mem-context>
|
|
2
|
+
# Recent Activity
|
|
3
|
+
|
|
4
|
+
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
|
5
|
+
|
|
6
|
+
### Feb 21, 2026
|
|
7
|
+
|
|
8
|
+
| ID | Time | T | Title | Read |
|
|
9
|
+
|----|------|---|-------|------|
|
|
10
|
+
| #3661 | 7:57 PM | 🔵 | Code quality review confirmed Plans 01-02 are production-ready with zero issues | ~791 |
|
|
11
|
+
</claude-mem-context>
|