rails-hyperdrive 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/CHANGELOG.md +295 -0
- data/LICENSE.txt +21 -0
- data/README.md +229 -0
- data/Rakefile +6 -0
- data/SECURITY.md +43 -0
- data/config/routes.rb +5 -0
- data/lib/generators/hyperdrive/content_sync_support.rb +54 -0
- data/lib/generators/hyperdrive/discover/discover_generator.rb +103 -0
- data/lib/generators/hyperdrive/gitignore_support.rb +28 -0
- data/lib/generators/hyperdrive/install/USAGE +22 -0
- data/lib/generators/hyperdrive/install/install_generator.rb +168 -0
- data/lib/generators/hyperdrive/install/templates/initializer.rb.tt +3 -0
- data/lib/generators/hyperdrive/install_summary.rb +70 -0
- data/lib/generators/hyperdrive/sync/USAGE +21 -0
- data/lib/generators/hyperdrive/sync/sync_generator.rb +40 -0
- data/lib/generators/hyperdrive/sync_runner.rb +70 -0
- data/lib/rails/hyperdrive/artifact_status.rb +86 -0
- data/lib/rails/hyperdrive/audit_header.rb +83 -0
- data/lib/rails/hyperdrive/auto_install.rb +98 -0
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +320 -0
- data/lib/rails/hyperdrive/claude_md_import.rb +69 -0
- data/lib/rails/hyperdrive/companion_discovery.rb +232 -0
- data/lib/rails/hyperdrive/console_executor.rb +64 -0
- data/lib/rails/hyperdrive/data/gem_categories.yml +52 -0
- data/lib/rails/hyperdrive/drift_verdict.rb +42 -0
- data/lib/rails/hyperdrive/eager_footprint.rb +48 -0
- data/lib/rails/hyperdrive/engine.rb +22 -0
- data/lib/rails/hyperdrive/index_document.rb +49 -0
- data/lib/rails/hyperdrive/install_layout.rb +45 -0
- data/lib/rails/hyperdrive/install_pipeline.rb +415 -0
- data/lib/rails/hyperdrive/install_plan.rb +78 -0
- data/lib/rails/hyperdrive/install_shell.rb +43 -0
- data/lib/rails/hyperdrive/lock_file.rb +171 -0
- data/lib/rails/hyperdrive/mcp_server.rb +80 -0
- data/lib/rails/hyperdrive/resources/skill.rb +63 -0
- data/lib/rails/hyperdrive/resources/stack_profile.rb +32 -0
- data/lib/rails/hyperdrive/safety/rack_middleware.rb +54 -0
- data/lib/rails/hyperdrive/skill_template.rb +52 -0
- data/lib/rails/hyperdrive/sql_safety.rb +28 -0
- data/lib/rails/hyperdrive/stack_profile.rb +176 -0
- data/lib/rails/hyperdrive/tools/base.rb +39 -0
- data/lib/rails/hyperdrive/tools/describe_app.rb +21 -0
- data/lib/rails/hyperdrive/tools/list_models.rb +76 -0
- data/lib/rails/hyperdrive/tools/list_routes.rb +33 -0
- data/lib/rails/hyperdrive/tools/locate_source.rb +86 -0
- data/lib/rails/hyperdrive/tools/lookup_doc.rb +60 -0
- data/lib/rails/hyperdrive/tools/run_ruby.rb +31 -0
- data/lib/rails/hyperdrive/tools/run_sql.rb +49 -0
- data/lib/rails/hyperdrive/tools/tail_logs.rb +65 -0
- data/lib/rails/hyperdrive/version.rb +5 -0
- data/lib/rails/hyperdrive.rb +37 -0
- data/lib/rails-hyperdrive.rb +2 -0
- data/lib/tasks/hyperdrive.rake +22 -0
- metadata +161 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "time"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require "bundler"
|
|
9
|
+
|
|
10
|
+
module Rails
|
|
11
|
+
module Hyperdrive
|
|
12
|
+
class CompanionDiscovery
|
|
13
|
+
TARGETS_KEY = "rails_hyperdrive_targets".freeze
|
|
14
|
+
ARTIFACTS_KEY = "rails_hyperdrive_artifacts".freeze
|
|
15
|
+
# Field-scoped metadata search is an undocumented passthrough to the
|
|
16
|
+
# rubygems search backend, so it fails open: if it ever stops matching, the
|
|
17
|
+
# query returns an empty 200 page and discovery reports no suggestions.
|
|
18
|
+
SEARCH_QUERY = "metadata.#{TARGETS_KEY}:*".freeze
|
|
19
|
+
SEARCH_ENDPOINT = "https://rubygems.org/api/v1/search.json".freeze
|
|
20
|
+
CACHE_RELATIVE_PATH = ".hyperdrive/discover_cache.json".freeze
|
|
21
|
+
PER_PAGE = 30
|
|
22
|
+
MAX_PAGES = 34 # ~1000 companions; a runaway-pagination backstop
|
|
23
|
+
CACHE_TTL = 24 * 60 * 60
|
|
24
|
+
OPEN_TIMEOUT = 5
|
|
25
|
+
READ_TIMEOUT = 5
|
|
26
|
+
|
|
27
|
+
# matched_target is nil for a universal "*" companion.
|
|
28
|
+
Suggestion = Struct.new(
|
|
29
|
+
:gem_name, :version, :targets, :artifacts,
|
|
30
|
+
:matched_target, :matched_version, :installed,
|
|
31
|
+
keyword_init: true
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Outcome of a run.
|
|
35
|
+
# status: :online — served live or fresh-cached results
|
|
36
|
+
# :stale — network failed; served an expired cache
|
|
37
|
+
# :unavailable — network failed and no cache to fall back to
|
|
38
|
+
Result = Struct.new(:suggestions, :warnings, :status, :age_seconds, :detail, keyword_init: true)
|
|
39
|
+
|
|
40
|
+
class Unavailable < StandardError
|
|
41
|
+
attr_reader :retry_after
|
|
42
|
+
|
|
43
|
+
def initialize(message, retry_after: nil)
|
|
44
|
+
super(message)
|
|
45
|
+
@retry_after = retry_after
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
Response = Struct.new(:code, :body, :retry_after, keyword_init: true)
|
|
50
|
+
|
|
51
|
+
class NetHttpFetcher
|
|
52
|
+
def get(uri)
|
|
53
|
+
uri = URI(uri)
|
|
54
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
55
|
+
http.use_ssl = uri.scheme == "https"
|
|
56
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
57
|
+
http.read_timeout = READ_TIMEOUT
|
|
58
|
+
resp = http.get(uri.request_uri)
|
|
59
|
+
Response.new(code: resp.code.to_i, body: resp.body.to_s, retry_after: resp["retry-after"])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(lockfile_path:, cache_path:, refresh: false, fetcher: NetHttpFetcher.new, clock: -> { Time.now.utc })
|
|
64
|
+
@lockfile_path = lockfile_path.to_s
|
|
65
|
+
@cache_path = cache_path.to_s
|
|
66
|
+
@refresh = refresh
|
|
67
|
+
@fetcher = fetcher
|
|
68
|
+
@clock = clock
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def run
|
|
72
|
+
warnings = []
|
|
73
|
+
now = @clock.call
|
|
74
|
+
candidates, status, age, detail = resolve_candidates(now)
|
|
75
|
+
|
|
76
|
+
return Result.new(suggestions: [], warnings: warnings, status: :unavailable, detail: detail) if status == :unavailable
|
|
77
|
+
|
|
78
|
+
suggestions = match(candidates, warnings)
|
|
79
|
+
Result.new(suggestions: suggestions, warnings: warnings, status: status, age_seconds: age, detail: detail)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def resolve_candidates(now)
|
|
85
|
+
cache = read_cache
|
|
86
|
+
if !@refresh && cache && fresh?(cache, now)
|
|
87
|
+
return [cache[:candidates], :online, age_of(cache, now), nil]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
begin
|
|
91
|
+
candidates = fetch_all
|
|
92
|
+
write_cache(candidates, now)
|
|
93
|
+
[candidates, :online, 0, nil]
|
|
94
|
+
rescue Unavailable => e
|
|
95
|
+
detail = unavailable_detail(e)
|
|
96
|
+
if cache
|
|
97
|
+
[cache[:candidates], :stale, age_of(cache, now), detail]
|
|
98
|
+
else
|
|
99
|
+
[[], :unavailable, nil, detail]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def unavailable_detail(error)
|
|
105
|
+
if error.retry_after
|
|
106
|
+
"rubygems rate-limited the request (retry after #{error.retry_after}s)"
|
|
107
|
+
else
|
|
108
|
+
"rubygems unreachable (#{error.message})"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def fetch_all
|
|
113
|
+
candidates = []
|
|
114
|
+
page = 1
|
|
115
|
+
loop do
|
|
116
|
+
batch = fetch_page(page)
|
|
117
|
+
candidates.concat(batch)
|
|
118
|
+
break if batch.size < PER_PAGE
|
|
119
|
+
page += 1
|
|
120
|
+
break if page > MAX_PAGES
|
|
121
|
+
end
|
|
122
|
+
candidates
|
|
123
|
+
rescue SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, IOError => e
|
|
124
|
+
raise Unavailable, e.message
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def fetch_page(page)
|
|
128
|
+
uri = "#{SEARCH_ENDPOINT}?query=#{URI.encode_www_form_component(SEARCH_QUERY)}&page=#{page}"
|
|
129
|
+
resp = @fetcher.get(uri)
|
|
130
|
+
|
|
131
|
+
case resp.code
|
|
132
|
+
when 200
|
|
133
|
+
parse_search(resp.body)
|
|
134
|
+
when 429
|
|
135
|
+
raise Unavailable.new("rate limited", retry_after: resp.retry_after)
|
|
136
|
+
else
|
|
137
|
+
raise Unavailable, "HTTP #{resp.code}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def parse_search(body)
|
|
142
|
+
data = JSON.parse(body)
|
|
143
|
+
return [] unless data.is_a?(Array)
|
|
144
|
+
data.map { |gem| { name: gem["name"].to_s, version: gem["version"].to_s, metadata: gem["metadata"] || {} } }
|
|
145
|
+
rescue JSON::ParserError => e
|
|
146
|
+
raise Unavailable, "malformed search response (#{e.message})"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def match(candidates, warnings)
|
|
150
|
+
installed = installed_gems
|
|
151
|
+
candidates.filter_map { |c| build_suggestion(c, installed, warnings) }
|
|
152
|
+
.sort_by { |s| [s.installed ? 0 : 1, s.gem_name] }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def build_suggestion(candidate, installed, warnings)
|
|
156
|
+
name = candidate[:name]
|
|
157
|
+
metadata = candidate[:metadata] || {}
|
|
158
|
+
targets = parse_list(metadata[TARGETS_KEY])
|
|
159
|
+
|
|
160
|
+
if targets.empty?
|
|
161
|
+
warnings << "#{name} #{candidate[:version]}: no targets declared (skipped)"
|
|
162
|
+
return nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
matched_target, matched_version =
|
|
166
|
+
if targets.include?("*")
|
|
167
|
+
[nil, nil]
|
|
168
|
+
else
|
|
169
|
+
hit = targets.find { |t| installed.key?(t) }
|
|
170
|
+
return nil unless hit
|
|
171
|
+
[hit, installed[hit]]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
Suggestion.new(
|
|
175
|
+
gem_name: name,
|
|
176
|
+
version: candidate[:version],
|
|
177
|
+
targets: targets,
|
|
178
|
+
artifacts: parse_list(metadata[ARTIFACTS_KEY]),
|
|
179
|
+
matched_target: matched_target,
|
|
180
|
+
matched_version: matched_version,
|
|
181
|
+
installed: installed.key?(name)
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def parse_list(raw)
|
|
186
|
+
raw.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def installed_gems
|
|
190
|
+
return {} unless File.exist?(@lockfile_path)
|
|
191
|
+
parser = ::Bundler::LockfileParser.new(File.read(@lockfile_path))
|
|
192
|
+
parser.specs.each_with_object({}) { |s, h| h[s.name.to_s] = s.version.to_s }
|
|
193
|
+
rescue StandardError
|
|
194
|
+
# A malformed/unreadable lockfile must not break discovery — treat the
|
|
195
|
+
# stack as empty (only `*`-target companions will then match).
|
|
196
|
+
{}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def read_cache
|
|
200
|
+
return nil unless File.exist?(@cache_path)
|
|
201
|
+
data = JSON.parse(File.read(@cache_path))
|
|
202
|
+
fetched_at = Time.iso8601(data["fetched_at"].to_s)
|
|
203
|
+
candidates = Array(data["candidates"]).map do |c|
|
|
204
|
+
{ name: c["name"].to_s, version: c["version"].to_s, metadata: c["metadata"] || {} }
|
|
205
|
+
end
|
|
206
|
+
{ fetched_at: fetched_at, candidates: candidates }
|
|
207
|
+
rescue JSON::ParserError, ArgumentError, TypeError
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def write_cache(candidates, now)
|
|
212
|
+
FileUtils.mkdir_p(File.dirname(@cache_path))
|
|
213
|
+
payload = {
|
|
214
|
+
"fetched_at" => now.iso8601,
|
|
215
|
+
"candidates" => candidates.map { |c| { "name" => c[:name], "version" => c[:version], "metadata" => c[:metadata] } }
|
|
216
|
+
}
|
|
217
|
+
File.write(@cache_path, JSON.pretty_generate(payload) + "\n")
|
|
218
|
+
rescue SystemCallError
|
|
219
|
+
# A non-writable cache dir must not break discovery.
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def fresh?(cache, now)
|
|
224
|
+
age_of(cache, now) < CACHE_TTL
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def age_of(cache, now)
|
|
228
|
+
now - cache[:fetched_at]
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require "stringio"
|
|
2
|
+
require "timeout"
|
|
3
|
+
|
|
4
|
+
module Rails
|
|
5
|
+
module Hyperdrive
|
|
6
|
+
module ConsoleExecutor
|
|
7
|
+
DEFAULT_TIMEOUT_SECONDS = 30
|
|
8
|
+
|
|
9
|
+
Result = Struct.new(:result, :stdout, :stderr, :elapsed_ms, :exception, keyword_init: true) do
|
|
10
|
+
def to_h
|
|
11
|
+
{
|
|
12
|
+
result: result.inspect,
|
|
13
|
+
stdout: stdout,
|
|
14
|
+
stderr: stderr,
|
|
15
|
+
elapsed_ms: elapsed_ms,
|
|
16
|
+
exception: exception && {
|
|
17
|
+
class: exception.class.name,
|
|
18
|
+
message: exception.message,
|
|
19
|
+
backtrace: Array(exception.backtrace).first(20)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def eval(code, timeout: DEFAULT_TIMEOUT_SECONDS)
|
|
28
|
+
original_stdout = $stdout
|
|
29
|
+
original_stderr = $stderr
|
|
30
|
+
captured_out = StringIO.new
|
|
31
|
+
captured_err = StringIO.new
|
|
32
|
+
$stdout = captured_out
|
|
33
|
+
$stderr = captured_err
|
|
34
|
+
|
|
35
|
+
started_at = monotonic_now
|
|
36
|
+
result = nil
|
|
37
|
+
exception = nil
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
::Timeout.timeout(timeout.to_f) do
|
|
41
|
+
result = TOPLEVEL_BINDING.eval(code, "(hyperdrive run_ruby)", 1)
|
|
42
|
+
end
|
|
43
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
44
|
+
exception = e
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Result.new(
|
|
48
|
+
result: result,
|
|
49
|
+
stdout: captured_out.string,
|
|
50
|
+
stderr: captured_err.string,
|
|
51
|
+
elapsed_ms: ((monotonic_now - started_at) * 1000).round,
|
|
52
|
+
exception: exception
|
|
53
|
+
)
|
|
54
|
+
ensure
|
|
55
|
+
$stdout = original_stdout
|
|
56
|
+
$stderr = original_stderr
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def monotonic_now
|
|
60
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Category assignment is reporting metadata only; nothing here is executed.
|
|
2
|
+
|
|
3
|
+
test:
|
|
4
|
+
- rspec
|
|
5
|
+
- rspec-rails
|
|
6
|
+
- minitest
|
|
7
|
+
- minitest-rails
|
|
8
|
+
- test-unit
|
|
9
|
+
- capybara
|
|
10
|
+
- cucumber
|
|
11
|
+
- cucumber-rails
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
- sidekiq
|
|
15
|
+
- resque
|
|
16
|
+
- delayed_job
|
|
17
|
+
- good_job
|
|
18
|
+
- solid_queue
|
|
19
|
+
- sucker_punch
|
|
20
|
+
- shoryuken
|
|
21
|
+
- que
|
|
22
|
+
|
|
23
|
+
auth:
|
|
24
|
+
- devise
|
|
25
|
+
- clearance
|
|
26
|
+
- sorcery
|
|
27
|
+
- authlogic
|
|
28
|
+
- rodauth
|
|
29
|
+
- rodauth-rails
|
|
30
|
+
|
|
31
|
+
authz:
|
|
32
|
+
- pundit
|
|
33
|
+
- cancancan
|
|
34
|
+
- action_policy
|
|
35
|
+
|
|
36
|
+
frontend:
|
|
37
|
+
- turbo-rails
|
|
38
|
+
- stimulus-rails
|
|
39
|
+
- importmap-rails
|
|
40
|
+
- jsbundling-rails
|
|
41
|
+
- cssbundling-rails
|
|
42
|
+
- view_component
|
|
43
|
+
- phlex
|
|
44
|
+
- phlex-rails
|
|
45
|
+
- hotwire-rails
|
|
46
|
+
|
|
47
|
+
db:
|
|
48
|
+
- pg
|
|
49
|
+
- mysql2
|
|
50
|
+
- sqlite3
|
|
51
|
+
- trilogy
|
|
52
|
+
- sequel
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "rails/hyperdrive/audit_header"
|
|
3
|
+
|
|
4
|
+
module Rails
|
|
5
|
+
module Hyperdrive
|
|
6
|
+
# The lock's source_sha is computed over the install-ready body before any
|
|
7
|
+
# header injection, so hashing an installed file's comparable bytes
|
|
8
|
+
# (disk_sha) reproduces it exactly for an unedited file.
|
|
9
|
+
module DriftVerdict
|
|
10
|
+
STATES = %i[current outdated edited missing orphaned].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# The install-ready body's hash — what the lock records as source_sha.
|
|
15
|
+
def body_sha(content)
|
|
16
|
+
Digest::SHA256.hexdigest(content.to_s)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# AuditHeader.strip's line matching can raise on binary content, so
|
|
20
|
+
# skill_support files compare as raw bytes.
|
|
21
|
+
def disk_sha(file, kind:)
|
|
22
|
+
return body_sha(File.binread(file)) if kind.to_s == "skill_support"
|
|
23
|
+
body_sha(AuditHeader.strip(File.read(file)))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A file counts as unedited when its comparable bytes still hash to what
|
|
27
|
+
# the lock recorded at install time, not to what the gem ships now.
|
|
28
|
+
def unedited?(file, lock_entry:)
|
|
29
|
+
disk_sha(file, kind: lock_entry.kind) == lock_entry.source_sha
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# gem_sha nil means the bundle no longer offers this destination.
|
|
33
|
+
def verdict(file:, kind:, lock_entry:, gem_sha:)
|
|
34
|
+
return File.exist?(file) ? :orphaned : :missing if gem_sha.nil?
|
|
35
|
+
return :missing unless File.exist?(file)
|
|
36
|
+
return :edited if lock_entry.nil?
|
|
37
|
+
return :edited if disk_sha(file, kind: kind) != lock_entry.source_sha
|
|
38
|
+
lock_entry.source_sha == gem_sha ? :current : :outdated
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "rails/hyperdrive/install_layout"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Hyperdrive
|
|
5
|
+
# Sizes the always-in-context set — the index-included guidelines — and
|
|
6
|
+
# returns the report as lines for a shell to print.
|
|
7
|
+
module EagerFootprint
|
|
8
|
+
# Soft cap on the assembled eager set — roughly six at-the-limit
|
|
9
|
+
# guidelines, or ~5% of a 200k context window.
|
|
10
|
+
TOTAL_WARN_TOKENS = 10_000
|
|
11
|
+
|
|
12
|
+
Line = Struct.new(:status, :message, :color, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def approx_tokens(body)
|
|
17
|
+
(body.to_s.length / 4.0).ceil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def lines(guidelines:)
|
|
21
|
+
return [] if guidelines.empty?
|
|
22
|
+
|
|
23
|
+
tokens = guidelines.sum { |e| approx_tokens(e[:body]) }
|
|
24
|
+
|
|
25
|
+
result = [Line.new(
|
|
26
|
+
status: :eager,
|
|
27
|
+
message: "#{guidelines.size} guideline(s), ~#{tokens} tokens always in context",
|
|
28
|
+
color: :cyan
|
|
29
|
+
)]
|
|
30
|
+
result << over_budget_line(guidelines) if tokens > TOTAL_WARN_TOKENS
|
|
31
|
+
result
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def over_budget_line(entries)
|
|
35
|
+
top = entries.sort_by { |e| -approx_tokens(e[:body]) }.first(2)
|
|
36
|
+
.map { |e| "#{e[:name]} ~#{approx_tokens(e[:body])}" }
|
|
37
|
+
Line.new(
|
|
38
|
+
status: :warn,
|
|
39
|
+
message: "eager context is over the ~#{TOTAL_WARN_TOKENS} token budget (largest: #{top.join(", ")}); " \
|
|
40
|
+
"trim them, or drop a line from #{InstallLayout::INDEX_PATH} to opt one out",
|
|
41
|
+
color: :yellow
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private_class_method :over_budget_line
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "rails/engine"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Hyperdrive
|
|
5
|
+
# Loads in every environment so an accidental non-dev bundle group doesn't
|
|
6
|
+
# break boot.
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace Rails::Hyperdrive
|
|
9
|
+
|
|
10
|
+
initializer "hyperdrive.warn_outside_development" do
|
|
11
|
+
unless Rails::Hyperdrive.dev_mode?
|
|
12
|
+
msg = "[hyperdrive] loaded outside development (Rails.env=#{::Rails.env}); MCP endpoints will return 403"
|
|
13
|
+
if ::Rails.logger
|
|
14
|
+
::Rails.logger.warn(msg)
|
|
15
|
+
else
|
|
16
|
+
warn(msg)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Rails
|
|
2
|
+
module Hyperdrive
|
|
3
|
+
# Renders index.md — the list of `@`-includes that decides which guidelines
|
|
4
|
+
# load eagerly. Pure: the caller supplies the existing content and writes
|
|
5
|
+
# the result.
|
|
6
|
+
module IndexDocument
|
|
7
|
+
GUIDELINE_PREFIX = "@guidelines/".freeze
|
|
8
|
+
|
|
9
|
+
Rendered = Struct.new(:content, :guidelines, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# previously_installed are the guideline basenames the lock already
|
|
14
|
+
# records. A guideline whose line the user deleted from an existing
|
|
15
|
+
# index.md is not re-added.
|
|
16
|
+
def render(guidelines:, existing:, previously_installed:)
|
|
17
|
+
included = guidelines.select do |g|
|
|
18
|
+
next true if existing.nil?
|
|
19
|
+
next true unless previously_installed.include?(g[:base])
|
|
20
|
+
existing.include?("#{GUIDELINE_PREFIX}#{g[:base]}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
lines = included.map { |g| "#{GUIDELINE_PREFIX}#{g[:base]}" }.sort
|
|
24
|
+
|
|
25
|
+
Rendered.new(
|
|
26
|
+
content: lines.empty? ? "" : lines.join("\n") + "\n",
|
|
27
|
+
# Only guidelines referenced by index.md load into context, so they
|
|
28
|
+
# alone make up the eager footprint.
|
|
29
|
+
guidelines: included.map { |g| { name: g[:base], body: g[:body] } }
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Amends an existing index in place, returning nil when there is nothing
|
|
34
|
+
# to add. Existing lines carry over untouched, so an orphan keeps its
|
|
35
|
+
# inclusion.
|
|
36
|
+
def amend(existing:, added:)
|
|
37
|
+
return nil if existing.nil?
|
|
38
|
+
|
|
39
|
+
lines = added.map { |base| "#{GUIDELINE_PREFIX}#{base}" }
|
|
40
|
+
return nil if lines.empty?
|
|
41
|
+
|
|
42
|
+
current = existing.split("\n").map(&:strip).reject(&:empty?)
|
|
43
|
+
return nil if (lines - current).empty?
|
|
44
|
+
|
|
45
|
+
(current + lines).uniq.sort.join("\n") + "\n"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Rails
|
|
2
|
+
module Hyperdrive
|
|
3
|
+
# The one place install paths and artifact names are built and parsed.
|
|
4
|
+
# Must stay require-free: the MCP server loads it and must not drag in the
|
|
5
|
+
# install machinery.
|
|
6
|
+
module InstallLayout
|
|
7
|
+
SKILLS_DIR = ".claude/skills".freeze
|
|
8
|
+
HYPERDRIVE_DIR = ".claude/hyperdrive".freeze
|
|
9
|
+
INDEX_PATH = "#{HYPERDRIVE_DIR}/index.md".freeze
|
|
10
|
+
LOCK_PATH = ".hyperdrive/lock.yml".freeze
|
|
11
|
+
|
|
12
|
+
ARTIFACT_TYPES = { "skill" => :skill, "skill_support" => :skill_support, "guideline" => :guideline }.freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def dest_for(type, final_name)
|
|
17
|
+
case type
|
|
18
|
+
when :skill then "#{SKILLS_DIR}/#{final_name}/SKILL.md"
|
|
19
|
+
when :guideline then "#{HYPERDRIVE_DIR}/guidelines/#{final_name}.md"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def installed_name(type, dest)
|
|
24
|
+
case type
|
|
25
|
+
when :skill then File.basename(File.dirname(dest))
|
|
26
|
+
when :skill_support then dest.split("/")[2] # .claude/skills/<name>/<relpath>
|
|
27
|
+
when :guideline then File.basename(dest, ".md")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def skill_dir_of(dest)
|
|
32
|
+
segments = dest.split("/")
|
|
33
|
+
File.join(*segments[0, 3]) # .claude/skills/<name>
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def postfixed_name(name, source_gem)
|
|
37
|
+
"#{name}--#{source_gem}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def base_name(name)
|
|
41
|
+
name.split("--").first.to_s
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|