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,54 @@
|
|
|
1
|
+
require "generators/hyperdrive/sync_runner"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Generators
|
|
5
|
+
module Hyperdrive
|
|
6
|
+
# Thor registers every public method defined directly on a generator
|
|
7
|
+
# class as a runnable command, so these shared helpers must stay in an
|
|
8
|
+
# included module or private.
|
|
9
|
+
module ContentSyncSupport
|
|
10
|
+
# Routes InstallPipeline's writes through Thor, so its output and
|
|
11
|
+
# `--dry-run` handling cover installed content too.
|
|
12
|
+
class ThorShell
|
|
13
|
+
def initialize(generator)
|
|
14
|
+
@generator = generator
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_file(path, content)
|
|
18
|
+
@generator.create_file(path, content, force: true)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def append_to_file(path, content)
|
|
22
|
+
@generator.append_to_file(path, content)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def remove_file(path)
|
|
26
|
+
@generator.remove_file(path)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def say_status(kind, message, color = nil)
|
|
30
|
+
@generator.say_status(kind, message, color)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def say(message = "")
|
|
34
|
+
@generator.say(message)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Thor's file-writing helpers all read options[:pretend], so mapping
|
|
39
|
+
# --dry-run on read keeps it in force for every step regardless of
|
|
40
|
+
# which one runs first.
|
|
41
|
+
def options
|
|
42
|
+
opts = super
|
|
43
|
+
opts[:dry_run] ? opts.merge(pretend: true) : opts
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def runner
|
|
49
|
+
@runner ||= SyncRunner.new(shell: ThorShell.new(self))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/base"
|
|
3
|
+
require "rails/hyperdrive"
|
|
4
|
+
require "rails/hyperdrive/companion_discovery"
|
|
5
|
+
require "generators/hyperdrive/gitignore_support"
|
|
6
|
+
|
|
7
|
+
module Rails
|
|
8
|
+
module Generators
|
|
9
|
+
module Hyperdrive
|
|
10
|
+
# Read-only with respect to the app: never edits the Gemfile and never installs gems.
|
|
11
|
+
class DiscoverGenerator < ::Rails::Generators::Base
|
|
12
|
+
include GitignoreSupport
|
|
13
|
+
|
|
14
|
+
CACHE_RULE = ::Rails::Hyperdrive::CompanionDiscovery::CACHE_RELATIVE_PATH
|
|
15
|
+
|
|
16
|
+
source_root __dir__
|
|
17
|
+
|
|
18
|
+
class_option :refresh, type: :boolean, default: false,
|
|
19
|
+
desc: "Ignore the cached results and re-query rubygems."
|
|
20
|
+
|
|
21
|
+
def verify_environment
|
|
22
|
+
return if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
23
|
+
say_status :error, "must be run inside a Rails app", :red
|
|
24
|
+
raise Thor::Error, "hyperdrive: not in a Rails app"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ensure_cache_gitignored
|
|
28
|
+
ensure_gitignored(CACHE_RULE)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def discover_and_report
|
|
32
|
+
result = ::Rails::Hyperdrive::CompanionDiscovery.new(
|
|
33
|
+
lockfile_path: ::Rails.root.join("Gemfile.lock").to_s,
|
|
34
|
+
cache_path: ::Rails.root.join(CACHE_RULE).to_s,
|
|
35
|
+
refresh: options[:refresh]
|
|
36
|
+
).run
|
|
37
|
+
|
|
38
|
+
if result.status == :unavailable
|
|
39
|
+
say_status :unavailable, "rubygems discovery unavailable — #{result.detail}; no cached results", :yellow
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
say_status :stale, "rubygems unreachable; showing #{format_age(result.age_seconds)} cached results — #{result.detail}", :yellow if result.status == :stale
|
|
44
|
+
|
|
45
|
+
report_suggestions(result.suggestions)
|
|
46
|
+
report_warnings(result.warnings)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
no_tasks do
|
|
50
|
+
def report_suggestions(suggestions)
|
|
51
|
+
if suggestions.empty?
|
|
52
|
+
say_status :none, "no rails-hyperdrive companion gems found for your stack", :blue
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
say ""
|
|
57
|
+
say "Found gems with rails-hyperdrive content for your stack:"
|
|
58
|
+
suggestions.each { |s| say " #{format_line(s)}" }
|
|
59
|
+
|
|
60
|
+
to_add = suggestions.reject(&:installed)
|
|
61
|
+
return if to_add.empty?
|
|
62
|
+
|
|
63
|
+
say ""
|
|
64
|
+
to_add.each { |s| say "Run: bundle add #{s.gem_name} --group=development" }
|
|
65
|
+
say "Then: bin/rails hyperdrive:init"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def format_line(suggestion)
|
|
69
|
+
marker = suggestion.installed ? "✓" : "!"
|
|
70
|
+
companion = "#{suggestion.gem_name} #{suggestion.version}"
|
|
71
|
+
lhs =
|
|
72
|
+
if suggestion.matched_target
|
|
73
|
+
"#{suggestion.matched_target} #{suggestion.matched_version} → #{companion}"
|
|
74
|
+
else
|
|
75
|
+
"#{companion} (applies to any stack)"
|
|
76
|
+
end
|
|
77
|
+
status = suggestion.installed ? "(installed)" : "(suggested)"
|
|
78
|
+
artifacts =
|
|
79
|
+
if suggestion.installed || suggestion.artifacts.empty?
|
|
80
|
+
""
|
|
81
|
+
else
|
|
82
|
+
" — ships #{suggestion.artifacts.join(" + ")}"
|
|
83
|
+
end
|
|
84
|
+
"#{marker} #{lhs}#{artifacts} #{status}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def report_warnings(warnings)
|
|
88
|
+
return if warnings.empty?
|
|
89
|
+
say ""
|
|
90
|
+
say_status :warn, "discovery skipped #{warnings.size} gem(s):", :yellow
|
|
91
|
+
warnings.each { |w| say " - #{w}" }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def format_age(seconds)
|
|
95
|
+
return "cached" unless seconds
|
|
96
|
+
hours = (seconds / 3600.0).round
|
|
97
|
+
hours <= 1 ? "~1h-old" : "~#{hours}h-old"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Rails
|
|
2
|
+
module Generators
|
|
3
|
+
module Hyperdrive
|
|
4
|
+
# Included as a module so its methods are not registered as Thor commands —
|
|
5
|
+
# Thor's method_added hook fires only for methods defined directly on the
|
|
6
|
+
# generator class.
|
|
7
|
+
module GitignoreSupport
|
|
8
|
+
GITIGNORE = ".gitignore".freeze
|
|
9
|
+
|
|
10
|
+
# The rule must name a specific file, never a directory — the lockfile
|
|
11
|
+
# in the same directory stays tracked.
|
|
12
|
+
def ensure_gitignored(rule)
|
|
13
|
+
abs = ::Rails.root.join(GITIGNORE)
|
|
14
|
+
unless File.exist?(abs)
|
|
15
|
+
create_file GITIGNORE, "#{rule}\n"
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
body = File.read(abs)
|
|
20
|
+
return if body.split("\n").any? { |line| line.strip == rule }
|
|
21
|
+
|
|
22
|
+
prefix = body.end_with?("\n") || body.empty? ? "" : "\n"
|
|
23
|
+
append_to_file GITIGNORE, "#{prefix}#{rule}\n"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Bootstraps Rails Hyperdrive in this app:
|
|
3
|
+
- adds the rails-hyperdrive server to .mcp.json (Claude Code config),
|
|
4
|
+
merging into any servers already configured there
|
|
5
|
+
- mounts Rails::Hyperdrive::Engine at /_hyperdrive in config/routes.rb (idempotent)
|
|
6
|
+
- discovers companion-gem skills + guidelines in the bundle and installs
|
|
7
|
+
them with audit headers (skills to .claude/skills/, guidelines to
|
|
8
|
+
.claude/hyperdrive/guidelines/)
|
|
9
|
+
- maintains the .claude/hyperdrive/index.md aggregator and injects one
|
|
10
|
+
@-include line into CLAUDE.md — both only while a companion gem ships
|
|
11
|
+
a guideline, and both removed when the last one goes
|
|
12
|
+
- tracks everything in .hyperdrive/lock.yml
|
|
13
|
+
|
|
14
|
+
Re-running re-syncs content and leaves locally-modified files untouched
|
|
15
|
+
(skip + warn). Routine content refresh is bin/rails hyperdrive:sync
|
|
16
|
+
(pass --overwrite to restore gem-shipped content).
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
bin/rails hyperdrive:init
|
|
20
|
+
bin/rails hyperdrive:init --mount-at /admin/hyperdrive
|
|
21
|
+
bin/rails hyperdrive:init --skip-content
|
|
22
|
+
bin/rails hyperdrive:init --dry-run
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/base"
|
|
3
|
+
require "json"
|
|
4
|
+
require "rails/hyperdrive/companion_discovery"
|
|
5
|
+
require "rails/hyperdrive/mcp_server"
|
|
6
|
+
require "generators/hyperdrive/content_sync_support"
|
|
7
|
+
require "generators/hyperdrive/gitignore_support"
|
|
8
|
+
|
|
9
|
+
module Rails
|
|
10
|
+
module Generators
|
|
11
|
+
module Hyperdrive
|
|
12
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
13
|
+
include ContentSyncSupport
|
|
14
|
+
include GitignoreSupport
|
|
15
|
+
|
|
16
|
+
ENGINE_MOUNT_TOKEN = "Rails::Hyperdrive::Engine"
|
|
17
|
+
DEFAULT_MOUNT_AT = "/_hyperdrive".freeze
|
|
18
|
+
|
|
19
|
+
MCP_JSON_PATH = ".mcp.json".freeze
|
|
20
|
+
MCP_SERVER_KEY = "rails-hyperdrive".freeze
|
|
21
|
+
|
|
22
|
+
GEMFILE = "Gemfile".freeze
|
|
23
|
+
BUNDLER_PLUGIN = "bundler-rails-hyperdrive".freeze
|
|
24
|
+
|
|
25
|
+
source_root File.expand_path("templates", __dir__)
|
|
26
|
+
|
|
27
|
+
class_option :mount_at, type: :string, default: DEFAULT_MOUNT_AT, desc: "Engine mount path."
|
|
28
|
+
class_option :skip_content, type: :boolean, default: false, desc: "Skip all .claude content, CLAUDE.md, and the lockfile; write only .mcp.json and the mount."
|
|
29
|
+
class_option :dry_run, type: :boolean, default: false, desc: "Show what would change; write nothing."
|
|
30
|
+
|
|
31
|
+
def verify_environment
|
|
32
|
+
runner.verify_environment!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def discover_artifacts
|
|
36
|
+
runner.discover_artifacts(skip: options[:skip_content])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The write is forced: Thor's conflict prompt would otherwise block the
|
|
40
|
+
# run waiting on stdin.
|
|
41
|
+
def write_mcp_json
|
|
42
|
+
existing = mcp_json_on_disk
|
|
43
|
+
document = existing ? parse_mcp_json(existing) : {}
|
|
44
|
+
return if document.nil?
|
|
45
|
+
|
|
46
|
+
(document["mcpServers"] ||= {})[MCP_SERVER_KEY] = mcp_server_entry
|
|
47
|
+
content = JSON.pretty_generate(document) + "\n"
|
|
48
|
+
|
|
49
|
+
if existing == content
|
|
50
|
+
say_status :unchanged, MCP_JSON_PATH, :blue
|
|
51
|
+
else
|
|
52
|
+
create_file MCP_JSON_PATH, content, force: true
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def ignore_discover_cache
|
|
57
|
+
ensure_gitignored(::Rails::Hyperdrive::CompanionDiscovery::CACHE_RELATIVE_PATH)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Any existing directive counts as registered — a path- or
|
|
61
|
+
# version-qualified line is a deliberate choice this must not
|
|
62
|
+
# duplicate or rewrite.
|
|
63
|
+
def register_bundler_plugin
|
|
64
|
+
gemfile = ::Rails.root.join(GEMFILE)
|
|
65
|
+
unless File.exist?(gemfile)
|
|
66
|
+
say_status :skip, "no #{GEMFILE} found; add plugin #{BUNDLER_PLUGIN.inspect} manually", :yellow
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
body = File.read(gemfile)
|
|
71
|
+
if body.match?(/^\s*plugin\s+["']#{BUNDLER_PLUGIN}["']/)
|
|
72
|
+
say_status :identical, "#{GEMFILE} (#{BUNDLER_PLUGIN} plugin already registered)", :blue
|
|
73
|
+
return
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
prefix = body.end_with?("\n") || body.empty? ? "" : "\n"
|
|
77
|
+
append_to_file GEMFILE, "#{prefix}plugin #{BUNDLER_PLUGIN.inspect}\n"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def write_initializer
|
|
81
|
+
return if mount_path == DEFAULT_MOUNT_AT
|
|
82
|
+
template "initializer.rb.tt", "config/initializers/hyperdrive.rb"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def mount_engine
|
|
86
|
+
routes_file = "config/routes.rb"
|
|
87
|
+
unless File.exist?(::Rails.root.join(routes_file))
|
|
88
|
+
say_status :skip, "no #{routes_file} found; skipping engine mount", :yellow
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
contents = File.read(::Rails.root.join(routes_file))
|
|
93
|
+
if contents.include?(ENGINE_MOUNT_TOKEN)
|
|
94
|
+
say_status :identical, "#{routes_file} (engine already mounted)", :blue
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
snippet = " mount Rails::Hyperdrive::Engine => \"#{mount_path}\" if Rails.env.development?\n"
|
|
99
|
+
inject_into_file routes_file, snippet, after: /Rails\.application\.routes\.draw do\s*\n/
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# `--skip-content` writes no lockfile either: the lock is a manifest of
|
|
103
|
+
# installed content, and an empty one would assert "zero files is the
|
|
104
|
+
# managed set". A later init or sync reconstructs the full state.
|
|
105
|
+
def sync_content
|
|
106
|
+
return if options[:skip_content]
|
|
107
|
+
runner.install(mode: :preserve)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def print_summary
|
|
111
|
+
say ""
|
|
112
|
+
say_status :done, "hyperdrive initialized", :green
|
|
113
|
+
say " Mount: #{mount_path} (in config/routes.rb)"
|
|
114
|
+
say " Server: #{::Rails::Hyperdrive::McpServer::TOOLS.size} MCP tools at http://localhost:3000#{mount_path}/mcp"
|
|
115
|
+
runner.summary_lines.each { |line| say line } unless options[:skip_content]
|
|
116
|
+
say ""
|
|
117
|
+
say " Next steps:"
|
|
118
|
+
say " 1. bin/rails server"
|
|
119
|
+
say " 2. Open Claude Code in this directory; it will read .mcp.json"
|
|
120
|
+
say " 3. Verify the connection: curl http://localhost:3000#{mount_path}/mcp"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
no_tasks do
|
|
124
|
+
def mcp_json_on_disk
|
|
125
|
+
abs = ::Rails.root.join(MCP_JSON_PATH)
|
|
126
|
+
File.exist?(abs) ? File.read(abs) : nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def mcp_server_entry
|
|
130
|
+
{
|
|
131
|
+
"url" => "http://localhost:3000#{mount_path}/mcp",
|
|
132
|
+
"type" => "http"
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# A file we can't parse is never overwritten — its contents are
|
|
137
|
+
# unrecoverable.
|
|
138
|
+
def parse_mcp_json(raw)
|
|
139
|
+
document = JSON.parse(raw)
|
|
140
|
+
return unmergeable_mcp_json("top-level value is not a JSON object") unless document.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
servers = document["mcpServers"]
|
|
143
|
+
unless servers.nil? || servers.is_a?(Hash)
|
|
144
|
+
return unmergeable_mcp_json('"mcpServers" is not a JSON object')
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
document
|
|
148
|
+
rescue JSON::ParserError => e
|
|
149
|
+
unmergeable_mcp_json(e.message.lines.first.to_s.strip)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def unmergeable_mcp_json(reason)
|
|
153
|
+
say_status :warn,
|
|
154
|
+
"#{MCP_JSON_PATH} left unchanged (#{reason}); fix it and re-run to add the #{MCP_SERVER_KEY} server",
|
|
155
|
+
:yellow
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def mount_path
|
|
160
|
+
raw = options[:mount_at].to_s
|
|
161
|
+
raw = "/" + raw unless raw.start_with?("/")
|
|
162
|
+
raw.length > 1 ? raw.chomp("/") : raw
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "rails/hyperdrive/install_layout"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Generators
|
|
5
|
+
module Hyperdrive
|
|
6
|
+
module InstallSummary
|
|
7
|
+
KIND_WIDTH = "guideline".length
|
|
8
|
+
KIND_ORDER = %w[skill guideline].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# The lock is the authoritative set: it includes untouched,
|
|
13
|
+
# locally-modified, and orphaned files.
|
|
14
|
+
def lines(entries)
|
|
15
|
+
entries = entries.to_a
|
|
16
|
+
return [] if entries.empty?
|
|
17
|
+
|
|
18
|
+
support, listed = entries.partition { |e| e.kind.to_s == "skill_support" }
|
|
19
|
+
# A carried SKILL.md entry can record an older source than its
|
|
20
|
+
# supporting files, so counts key on the installed directory name,
|
|
21
|
+
# which is unique across sources.
|
|
22
|
+
support_counts = support
|
|
23
|
+
.group_by { |e| ::Rails::Hyperdrive::InstallLayout.installed_name(:skill_support, e.path.to_s) }
|
|
24
|
+
.transform_values(&:size)
|
|
25
|
+
|
|
26
|
+
out = [" #{installed_counts(listed)}", ""]
|
|
27
|
+
group_by_source(listed).each do |source, group|
|
|
28
|
+
out << " #{source}"
|
|
29
|
+
group.each do |entry|
|
|
30
|
+
name = display_name(entry)
|
|
31
|
+
count = entry.kind.to_s == "skill" ? support_counts[name].to_i : 0
|
|
32
|
+
suffix = count.positive? ? " (+#{quantify(count, "file")})" : ""
|
|
33
|
+
out << " #{entry.kind.to_s.ljust(KIND_WIDTH)} #{name}#{suffix}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
out
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def installed_counts(entries)
|
|
40
|
+
counts = entries.group_by { |e| e.kind.to_s }.transform_values(&:size)
|
|
41
|
+
"Installed #{quantify(counts["skill"].to_i, "skill")}, #{quantify(counts["guideline"].to_i, "guideline")}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def group_by_source(entries)
|
|
45
|
+
entries
|
|
46
|
+
.group_by { |e| e.source_label.to_s }
|
|
47
|
+
.sort_by { |source, group| [group.first.source_gem == "internal" ? 1 : 0, source] }
|
|
48
|
+
.map do |source, group|
|
|
49
|
+
[source, group.sort_by { |e| [KIND_ORDER.index(e.kind.to_s) || KIND_ORDER.size, display_name(e)] }]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def display_name(entry)
|
|
54
|
+
path = entry.path.to_s
|
|
55
|
+
|
|
56
|
+
# A kind outside the install layout can only come from a hand-edited
|
|
57
|
+
# lock, so it degrades to the filename instead of printing nothing.
|
|
58
|
+
type = ::Rails::Hyperdrive::InstallLayout::ARTIFACT_TYPES[entry.kind.to_s]
|
|
59
|
+
type ? ::Rails::Hyperdrive::InstallLayout.installed_name(type, path) : File.basename(path, ".md")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def quantify(count, noun)
|
|
63
|
+
"#{count} #{noun}#{"s" unless count == 1}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private_class_method :installed_counts, :group_by_source, :display_name, :quantify
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Syncs Rails Hyperdrive content to match the current bundle (run after
|
|
3
|
+
`bundle update` or after adding a companion gem):
|
|
4
|
+
- discovers companion-gem skills + guidelines in the bundle and installs
|
|
5
|
+
them with audit headers (skills to .claude/skills/, guidelines to
|
|
6
|
+
.claude/hyperdrive/guidelines/)
|
|
7
|
+
- maintains the .claude/hyperdrive/index.md aggregator and the single
|
|
8
|
+
@-include line in CLAUDE.md — both only while a companion gem ships a
|
|
9
|
+
guideline, and both removed when the last one goes
|
|
10
|
+
- tracks everything in .hyperdrive/lock.yml
|
|
11
|
+
|
|
12
|
+
Locally-modified files are left untouched (skip + warn). Pass --overwrite
|
|
13
|
+
to restore them to the gem-shipped content.
|
|
14
|
+
|
|
15
|
+
Touches no bootstrap artifact: .mcp.json, the engine mount, the optional
|
|
16
|
+
initializer, and .gitignore belong to hyperdrive:init and are left alone.
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
bin/rails hyperdrive:sync
|
|
20
|
+
bin/rails hyperdrive:sync --overwrite
|
|
21
|
+
bin/rails hyperdrive:sync --dry-run
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/base"
|
|
3
|
+
require "generators/hyperdrive/content_sync_support"
|
|
4
|
+
|
|
5
|
+
module Rails
|
|
6
|
+
module Generators
|
|
7
|
+
module Hyperdrive
|
|
8
|
+
# Content-only by contract: no step may write a bootstrap artifact
|
|
9
|
+
# (.mcp.json, the engine mount, the initializer, the .gitignore rule).
|
|
10
|
+
class SyncGenerator < ::Rails::Generators::Base
|
|
11
|
+
include ContentSyncSupport
|
|
12
|
+
|
|
13
|
+
# No templates are rendered; source_root exists so Rails resolves the
|
|
14
|
+
# sibling USAGE file for `--help`.
|
|
15
|
+
source_root File.expand_path("templates", __dir__)
|
|
16
|
+
|
|
17
|
+
class_option :overwrite, type: :boolean, default: false, desc: "Restore locally-modified managed files to the gem-shipped content."
|
|
18
|
+
class_option :dry_run, type: :boolean, default: false, desc: "Show what would change; write nothing."
|
|
19
|
+
|
|
20
|
+
def verify_environment
|
|
21
|
+
runner.verify_environment!
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def discover_artifacts
|
|
25
|
+
runner.discover_artifacts
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def sync_content
|
|
29
|
+
runner.install(mode: options[:overwrite] ? :overwrite : :preserve)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def print_summary
|
|
33
|
+
say ""
|
|
34
|
+
say_status :done, "hyperdrive synced", :green
|
|
35
|
+
runner.summary_lines.each { |line| say line }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "thor"
|
|
2
|
+
require "rails/hyperdrive"
|
|
3
|
+
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
4
|
+
require "rails/hyperdrive/install_pipeline"
|
|
5
|
+
require "generators/hyperdrive/install_summary"
|
|
6
|
+
|
|
7
|
+
module Rails
|
|
8
|
+
module Generators
|
|
9
|
+
module Hyperdrive
|
|
10
|
+
# Owns the init/sync sequence. Each phase is an idempotent memoizer and
|
|
11
|
+
# `install` forces the ones it needs, so no call order can install with a
|
|
12
|
+
# half-built input set.
|
|
13
|
+
class SyncRunner
|
|
14
|
+
def initialize(shell:, root: nil)
|
|
15
|
+
@shell = shell
|
|
16
|
+
@root = root&.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def verify_environment!
|
|
20
|
+
unless defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
21
|
+
@shell.say_status :error, "must be run inside a Rails app", :red
|
|
22
|
+
raise Thor::Error, "hyperdrive: not in a Rails app"
|
|
23
|
+
end
|
|
24
|
+
unless ::Rails.respond_to?(:env) && ::Rails.env.development?
|
|
25
|
+
env = ::Rails.respond_to?(:env) ? ::Rails.env.to_s : "unknown"
|
|
26
|
+
warn "hyperdrive: must run with Rails.env=development (current: #{env})"
|
|
27
|
+
raise Thor::Error, "hyperdrive: refuse to run outside development (Rails.env=#{env})"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def discover_artifacts(skip: false)
|
|
32
|
+
@artifacts ||= skip ? [] : ::Rails::Hyperdrive::BundlerArtifactDiscovery.discover(warnings: warnings)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def install(mode:)
|
|
36
|
+
@pipeline = ::Rails::Hyperdrive::InstallPipeline.new(
|
|
37
|
+
root: root,
|
|
38
|
+
shell: @shell,
|
|
39
|
+
artifacts: discover_artifacts,
|
|
40
|
+
mode: mode,
|
|
41
|
+
warnings: warnings
|
|
42
|
+
)
|
|
43
|
+
@pipeline.call
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def summary_lines
|
|
47
|
+
InstallSummary.lines(lock_entries)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Resolved lazily so verify_environment! can report a missing Rails app
|
|
53
|
+
# before anything dereferences ::Rails.root.
|
|
54
|
+
def root
|
|
55
|
+
@root ||= ::Rails.root.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def warnings
|
|
59
|
+
@warnings ||= []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def lock_entries
|
|
63
|
+
entries = []
|
|
64
|
+
@pipeline&.lock&.each_entry { |e| entries << e }
|
|
65
|
+
entries
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
require "rails/hyperdrive/drift_verdict"
|
|
2
|
+
require "rails/hyperdrive/install_layout"
|
|
3
|
+
require "rails/hyperdrive/install_plan"
|
|
4
|
+
require "rails/hyperdrive/lock_file"
|
|
5
|
+
|
|
6
|
+
module Rails
|
|
7
|
+
module Hyperdrive
|
|
8
|
+
# The comparison is against the lock manifest alone — installed files are
|
|
9
|
+
# never read, so an edited or deleted file does not change a verdict.
|
|
10
|
+
class ArtifactStatus
|
|
11
|
+
STATES = %i[installed missing outdated orphaned].freeze
|
|
12
|
+
|
|
13
|
+
Entry = Struct.new(:path, :state, :artifact, :locked_source, :bundle_source, keyword_init: true) do
|
|
14
|
+
def to_s
|
|
15
|
+
case state
|
|
16
|
+
when :missing then "#{path} (from #{bundle_source})"
|
|
17
|
+
when :outdated then "#{path} (#{locked_source} → #{bundle_source})"
|
|
18
|
+
when :orphaned then "#{path} (source #{locked_source} no longer in bundle)"
|
|
19
|
+
else path
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.compare(root:, artifacts:)
|
|
25
|
+
new(root: root, artifacts: artifacts).tap(&:compare)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attr_reader :entries
|
|
29
|
+
|
|
30
|
+
def initialize(root:, artifacts:)
|
|
31
|
+
@root = File.expand_path(root.to_s)
|
|
32
|
+
@artifacts = artifacts
|
|
33
|
+
@entries = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def compare
|
|
37
|
+
lock = LockFile.load(File.join(@root, InstallLayout::LOCK_PATH))
|
|
38
|
+
offered = {}
|
|
39
|
+
|
|
40
|
+
InstallPlan.build(@artifacts, lock: lock).entries.each do |plan_entry|
|
|
41
|
+
offered[plan_entry.dest] = [DriftVerdict.body_sha(plan_entry.install_ready_body), plan_entry.source_label, plan_entry.type]
|
|
42
|
+
plan_entry.support_files.each do |file|
|
|
43
|
+
offered[file[:dest]] = [DriftVerdict.body_sha(file[:body]), plan_entry.source_label, :skill_support]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
offered.each do |path, (gem_sha, source_label, type)|
|
|
48
|
+
locked = lock.entry(path)
|
|
49
|
+
state =
|
|
50
|
+
if locked.nil? then :missing
|
|
51
|
+
elsif locked.source_sha == gem_sha then :installed
|
|
52
|
+
else :outdated
|
|
53
|
+
end
|
|
54
|
+
@entries << Entry.new(
|
|
55
|
+
path: path, state: state, artifact: type,
|
|
56
|
+
locked_source: locked&.source_label, bundle_source: source_label
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
lock.each_entry do |locked|
|
|
61
|
+
next if offered.key?(locked.path)
|
|
62
|
+
|
|
63
|
+
# A disabled artifact left on disk was reported at install time; the
|
|
64
|
+
# bundle still ships it, so it is not an orphan.
|
|
65
|
+
type = InstallLayout::ARTIFACT_TYPES[locked.kind]
|
|
66
|
+
next if type && InstallPlan.disabled_dest?(lock, type, locked.path)
|
|
67
|
+
|
|
68
|
+
@entries << Entry.new(
|
|
69
|
+
path: locked.path, state: :orphaned, artifact: locked.kind&.to_sym,
|
|
70
|
+
locked_source: locked.source_label, bundle_source: nil
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
STATES.each do |state|
|
|
78
|
+
define_method(state) { @entries.select { |e| e.state == state } }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def stale?
|
|
82
|
+
!missing.empty? || !outdated.empty? || !orphaned.empty?
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|