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,83 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Hyperdrive
|
|
5
|
+
module AuditHeader
|
|
6
|
+
YAML_LINE = /\A#\s*hyperdrive:/.freeze
|
|
7
|
+
HTML_LINE = /\A<!--\s*hyperdrive:.*-->\s*\z/.freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def fields(source_gem:, version:, sha256:, installed_at: Time.now.utc)
|
|
12
|
+
[
|
|
13
|
+
"source=#{source_gem}@#{version}",
|
|
14
|
+
"sha256=#{sha256}",
|
|
15
|
+
"installed_at=#{installed_at.iso8601}"
|
|
16
|
+
]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def build(source_gem:, version:, sha256:, installed_at: Time.now.utc)
|
|
20
|
+
fields(source_gem: source_gem, version: version, sha256: sha256, installed_at: installed_at)
|
|
21
|
+
.map { |f| "# hyperdrive: #{f}" }
|
|
22
|
+
.join("\n")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build_html(source_gem:, version:, sha256:, installed_at: Time.now.utc)
|
|
26
|
+
fields(source_gem: source_gem, version: version, sha256: sha256, installed_at: installed_at)
|
|
27
|
+
.map { |f| "<!-- hyperdrive: #{f} -->" }
|
|
28
|
+
.join("\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def inject_into_frontmatter(body, header_block)
|
|
32
|
+
lines = body.lines
|
|
33
|
+
unless lines.first&.strip == "---"
|
|
34
|
+
return "---\n#{header_block}\n---\n\n#{body}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
closing_index = lines[1..].index { |l| l.strip == "---" }
|
|
38
|
+
return body unless closing_index
|
|
39
|
+
|
|
40
|
+
absolute_closing = closing_index + 1
|
|
41
|
+
lines.insert(absolute_closing, header_block + "\n")
|
|
42
|
+
lines.join
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def prepend_html(body, header_block)
|
|
46
|
+
"#{header_block}\n\n#{body}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Strips only the contiguous injected block, never stray "# hyperdrive:"
|
|
50
|
+
# or "<!-- -->" lines elsewhere in the body.
|
|
51
|
+
def strip(body)
|
|
52
|
+
lines = body.lines
|
|
53
|
+
if lines.first&.strip == "---"
|
|
54
|
+
strip_yaml(lines)
|
|
55
|
+
else
|
|
56
|
+
strip_html(lines)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def strip_yaml(lines)
|
|
61
|
+
closing_index = lines[1..].index { |l| l.strip == "---" }
|
|
62
|
+
return lines.join unless closing_index
|
|
63
|
+
|
|
64
|
+
absolute_closing = closing_index + 1
|
|
65
|
+
first = (1...absolute_closing).find { |i| lines[i] =~ YAML_LINE }
|
|
66
|
+
return lines.join unless first
|
|
67
|
+
|
|
68
|
+
last = first
|
|
69
|
+
last += 1 while last < absolute_closing && lines[last] =~ YAML_LINE
|
|
70
|
+
(lines[0...first] + lines[last..]).join
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def strip_html(lines)
|
|
74
|
+
return lines.join unless lines.first =~ HTML_LINE
|
|
75
|
+
|
|
76
|
+
i = 0
|
|
77
|
+
i += 1 while lines[i] =~ HTML_LINE
|
|
78
|
+
i += 1 if lines[i] && lines[i].strip.empty?
|
|
79
|
+
lines[i..].join
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
require "bundler"
|
|
2
|
+
require "rails/hyperdrive/artifact_status"
|
|
3
|
+
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
4
|
+
require "rails/hyperdrive/install_layout"
|
|
5
|
+
require "rails/hyperdrive/install_pipeline"
|
|
6
|
+
require "rails/hyperdrive/install_shell"
|
|
7
|
+
|
|
8
|
+
module Rails
|
|
9
|
+
module Hyperdrive
|
|
10
|
+
# Requires no booted Rails and never raises — every failure comes back as
|
|
11
|
+
# a skipped result.
|
|
12
|
+
module AutoInstall
|
|
13
|
+
DEVELOPMENT = "development".freeze
|
|
14
|
+
|
|
15
|
+
Result = Struct.new(:installed, :outdated, :orphaned, :unwired, :skipped, :error, keyword_init: true) do
|
|
16
|
+
def ran?
|
|
17
|
+
skipped.nil?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def installed_anything?
|
|
21
|
+
!Array(installed).empty?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def messages
|
|
25
|
+
return [] unless ran?
|
|
26
|
+
lines = []
|
|
27
|
+
unless Array(installed).empty?
|
|
28
|
+
lines << "installed #{installed.size} artifact(s):"
|
|
29
|
+
installed.each { |path| lines << " #{path}" }
|
|
30
|
+
end
|
|
31
|
+
lines << "installed guideline(s) are not in context yet — run bin/rails hyperdrive:sync" if unwired
|
|
32
|
+
stale = Array(outdated) + Array(orphaned)
|
|
33
|
+
unless stale.empty?
|
|
34
|
+
lines << "#{stale.size} artifact(s) need attention — run bin/rails hyperdrive:sync"
|
|
35
|
+
stale.each { |entry| lines << " #{entry}" }
|
|
36
|
+
end
|
|
37
|
+
lines
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
def run(root: Dir.pwd, env: current_env, io: nil)
|
|
44
|
+
root = File.expand_path(root.to_s)
|
|
45
|
+
|
|
46
|
+
return skip(:not_development) unless development?(env)
|
|
47
|
+
return skip(:not_initialized) unless File.exist?(File.join(root, InstallLayout::LOCK_PATH))
|
|
48
|
+
|
|
49
|
+
artifacts = BundlerArtifactDiscovery.discover
|
|
50
|
+
status = ArtifactStatus.compare(root: root, artifacts: artifacts)
|
|
51
|
+
|
|
52
|
+
installed = []
|
|
53
|
+
unwired = false
|
|
54
|
+
|
|
55
|
+
unless status.missing.empty?
|
|
56
|
+
pipeline = InstallPipeline.new(
|
|
57
|
+
root: root,
|
|
58
|
+
shell: InstallShell.new(root: root, io: io),
|
|
59
|
+
artifacts: artifacts,
|
|
60
|
+
mode: :additive
|
|
61
|
+
)
|
|
62
|
+
installed = pipeline.call.installed
|
|
63
|
+
# This runs from a bundle install, which must not edit CLAUDE.md, so a
|
|
64
|
+
# guideline landing in an app with no import line stays out of context
|
|
65
|
+
# until the user runs a sync.
|
|
66
|
+
unwired = pipeline.lock.claude_md_state.nil? && (installed & pipeline.lock.guideline_paths).any?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
Result.new(installed: installed, outdated: status.outdated, orphaned: status.orphaned, unwired: unwired)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
skip(:error, e)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Rails may never boot in this process, and this guard fronts a write
|
|
75
|
+
# into the working tree, which a deploy or a CI container must never
|
|
76
|
+
# have modified underneath it.
|
|
77
|
+
def development?(env)
|
|
78
|
+
return false unless env.to_s == DEVELOPMENT
|
|
79
|
+
return false if ENV["CI"] && !ENV["CI"].empty?
|
|
80
|
+
!frozen_bundle?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def current_env
|
|
84
|
+
ENV["RAILS_ENV"] || ENV["RACK_ENV"] || DEVELOPMENT
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def frozen_bundle?
|
|
88
|
+
::Bundler.respond_to?(:frozen_bundle?) && ::Bundler.frozen_bundle?
|
|
89
|
+
rescue StandardError
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def skip(reason, error = nil)
|
|
94
|
+
Result.new(installed: [], outdated: [], orphaned: [], skipped: reason, error: error)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "bundler"
|
|
3
|
+
require "rails/hyperdrive/skill_template"
|
|
4
|
+
|
|
5
|
+
module Rails
|
|
6
|
+
module Hyperdrive
|
|
7
|
+
module BundlerArtifactDiscovery
|
|
8
|
+
SKILL_FILE_NAMES = ["SKILL.md", "SKILL.md.erb"].freeze
|
|
9
|
+
|
|
10
|
+
Artifact = Struct.new(
|
|
11
|
+
:name, :description, :target_gem, :versions, :artifact_type,
|
|
12
|
+
:source_gem, :path, :body, :spec_version, :support_files,
|
|
13
|
+
keyword_init: true
|
|
14
|
+
) do
|
|
15
|
+
def skill?
|
|
16
|
+
artifact_type == :skill
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def guideline?
|
|
20
|
+
artifact_type == :guideline
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{
|
|
25
|
+
name: name, description: description, target_gem: target_gem,
|
|
26
|
+
versions: versions, artifact_type: artifact_type,
|
|
27
|
+
source_gem: source_gem, path: path, spec_version: spec_version
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Non-fatal problems are appended to `warnings` and the artifact is
|
|
35
|
+
# dropped; discovery never raises.
|
|
36
|
+
def discover(specs: nil, warnings: [])
|
|
37
|
+
specs ||= safe_bundler_specs
|
|
38
|
+
resolved = specs.each_with_object({}) { |s, h| h[s.name.to_s] = s.version }
|
|
39
|
+
|
|
40
|
+
candidates = []
|
|
41
|
+
specs.each do |spec|
|
|
42
|
+
each_artifact_path(spec, warnings: warnings) do |path, type|
|
|
43
|
+
artifact = parse(path, source_spec: spec, type: type, resolved: resolved, warnings: warnings)
|
|
44
|
+
candidates << artifact if artifact
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Collapse same-name variants within one source gem (highest
|
|
49
|
+
# spec_version, path as tiebreak); never across sources — composite
|
|
50
|
+
# identity is (name, source_gem).
|
|
51
|
+
candidates.group_by { |a| [a.name, a.source_gem, a.artifact_type] }.map do |_key, group|
|
|
52
|
+
group.max_by { |a| [Gem::Version.new(a.spec_version), a.path] }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def each_artifact_path(spec, warnings: [])
|
|
57
|
+
skill_paths(spec, warnings: warnings).each { |p| yield p, :skill }
|
|
58
|
+
guideline_paths(spec).each { |p| yield p, :guideline }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def skill_paths(spec, warnings: [])
|
|
62
|
+
roots = [File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "skills")]
|
|
63
|
+
if (override = skills_dir_override(spec))
|
|
64
|
+
roots << File.join(spec.full_gem_path, override)
|
|
65
|
+
end
|
|
66
|
+
found = roots.flat_map { |root| Dir.glob(File.join(root, "**", "{SKILL.md,SKILL.md.erb}")) }.uniq
|
|
67
|
+
found.group_by { |p| File.dirname(p) }.flat_map do |dir, group|
|
|
68
|
+
next group unless group.size > 1
|
|
69
|
+
warnings << "skip #{File.join(dir, "SKILL.md.erb")}: SKILL.md in the same directory takes precedence"
|
|
70
|
+
group.select { |p| File.basename(p) == "SKILL.md" }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def guideline_paths(spec)
|
|
75
|
+
root = File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "guidelines")
|
|
76
|
+
Dir.glob(File.join(root, "*.md"))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# ".." segments are rejected to prevent escaping the gem root.
|
|
80
|
+
def skills_dir_override(spec)
|
|
81
|
+
return nil unless spec.respond_to?(:metadata)
|
|
82
|
+
raw = spec.metadata && spec.metadata["rails_hyperdrive_skills_dir"]
|
|
83
|
+
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
84
|
+
return nil if raw.to_s.split(%r{[/\\]}).include?("..")
|
|
85
|
+
raw.to_s
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse(path, source_spec:, type:, resolved:, warnings:)
|
|
89
|
+
body = File.read(path)
|
|
90
|
+
if erb_template?(path)
|
|
91
|
+
begin
|
|
92
|
+
body = SkillTemplate.render(body, resolved: resolved)
|
|
93
|
+
rescue SyntaxError, StandardError => e
|
|
94
|
+
warnings << "skip #{path}: ERB render failed (#{e.message})"
|
|
95
|
+
return nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
frontmatter, _rest = split_frontmatter(body)
|
|
99
|
+
unless frontmatter
|
|
100
|
+
warnings << "skip #{path}: missing or malformed frontmatter"
|
|
101
|
+
return nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
meta = YAML.safe_load(frontmatter, permitted_classes: [Symbol]) || {}
|
|
105
|
+
name = meta["name"]
|
|
106
|
+
description = meta["description"]
|
|
107
|
+
versions = meta["versions"]
|
|
108
|
+
|
|
109
|
+
unless name && description && meta["gem"] && versions
|
|
110
|
+
warnings << "skip #{path}: missing a required field (name, description, gem, versions)"
|
|
111
|
+
return nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
targets = parse_targets(meta["gem"])
|
|
115
|
+
if targets.nil? || targets.empty?
|
|
116
|
+
warnings << "skip #{name} (from #{source_spec.name}): gem: must name a gem, a comma-separated list, or a YAML list"
|
|
117
|
+
return nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
matched = match_targets(targets, versions, resolved)
|
|
121
|
+
if matched.empty?
|
|
122
|
+
warnings << "skip #{name} (from #{source_spec.name}): #{no_match_reason(targets, versions, resolved)}"
|
|
123
|
+
return nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
Artifact.new(
|
|
127
|
+
name: name.to_s,
|
|
128
|
+
description: description.to_s,
|
|
129
|
+
target_gem: matched,
|
|
130
|
+
versions: versions,
|
|
131
|
+
artifact_type: type,
|
|
132
|
+
source_gem: source_spec.name.to_s,
|
|
133
|
+
path: path,
|
|
134
|
+
body: body,
|
|
135
|
+
spec_version: source_spec.version.to_s,
|
|
136
|
+
support_files:
|
|
137
|
+
if type == :skill
|
|
138
|
+
conditioned_support_files(
|
|
139
|
+
File.dirname(path), meta["conditional"],
|
|
140
|
+
resolved: resolved, warnings: warnings,
|
|
141
|
+
label: "#{name} (from #{source_spec.name})"
|
|
142
|
+
)
|
|
143
|
+
else
|
|
144
|
+
[]
|
|
145
|
+
end
|
|
146
|
+
)
|
|
147
|
+
rescue Psych::SyntaxError
|
|
148
|
+
warnings << "skip #{path}: malformed YAML frontmatter"
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Everything in a skill's directory besides SKILL.md(.erb) ships as raw
|
|
153
|
+
# bytes, with no frontmatter contract. ".." segments are rejected to keep
|
|
154
|
+
# the tree inside the skill directory.
|
|
155
|
+
def support_files_for(skill_dir)
|
|
156
|
+
Dir.glob(File.join(skill_dir, "**", "*")).filter_map do |file|
|
|
157
|
+
next unless File.file?(file)
|
|
158
|
+
rel = file.delete_prefix("#{skill_dir}/")
|
|
159
|
+
next if SKILL_FILE_NAMES.include?(File.basename(rel))
|
|
160
|
+
next if rel.split(%r{[/\\]}).include?("..")
|
|
161
|
+
{ path: rel, body: File.binread(file) }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def conditioned_support_files(skill_dir, conditional, resolved:, warnings:, label:)
|
|
166
|
+
files = support_files_for(skill_dir)
|
|
167
|
+
files = apply_conditional_filter(files, conditional, resolved: resolved, warnings: warnings, label: label)
|
|
168
|
+
render_support_templates(files, skill_dir, resolved: resolved, warnings: warnings)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Fail-open: a malformed condition warns and installs its file
|
|
172
|
+
# unconditionally — a surplus supporting file is harmless, while a
|
|
173
|
+
# missing one breaks links from SKILL.md.
|
|
174
|
+
def apply_conditional_filter(files, conditional, resolved:, warnings:, label:)
|
|
175
|
+
return files if conditional.nil?
|
|
176
|
+
unless conditional.is_a?(Hash)
|
|
177
|
+
warnings << "#{label}: conditional: must be a map of supporting-file path to {gem:, versions:}; installing all supporting files"
|
|
178
|
+
return files
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
conditions = conditional.transform_keys(&:to_s)
|
|
182
|
+
shipped = files.map { |f| f[:path] }
|
|
183
|
+
conditions.each_key do |key|
|
|
184
|
+
if SKILL_FILE_NAMES.include?(key)
|
|
185
|
+
warnings << "#{label}: conditional key '#{key}' ignored; the skill's own gem:/versions: gate the whole skill"
|
|
186
|
+
elsif !shipped.include?(key)
|
|
187
|
+
warnings << "#{label}: conditional key '#{key}' names no shipped supporting file"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
files.select do |file|
|
|
192
|
+
next true unless conditions.key?(file[:path])
|
|
193
|
+
conditional_satisfied?(file[:path], conditions[file[:path]], resolved, warnings: warnings, label: label)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def conditional_satisfied?(key, entry, resolved, warnings:, label:)
|
|
198
|
+
unless entry.is_a?(Hash)
|
|
199
|
+
warnings << "#{label}: conditional entry for '#{key}' must be a map with gem: (and optional versions:); installing the file"
|
|
200
|
+
return true
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
targets = entry["gem"] && parse_targets(entry["gem"])
|
|
204
|
+
if targets.nil? || targets.empty?
|
|
205
|
+
warnings << "#{label}: conditional entry for '#{key}' needs gem: naming a gem, a comma-separated list, or a YAML list; installing the file"
|
|
206
|
+
return true
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
versions = entry["versions"]
|
|
210
|
+
if malformed_requirements?(versions)
|
|
211
|
+
warnings << "#{label}: conditional entry for '#{key}' has an unparsable versions: requirement; installing the file"
|
|
212
|
+
return true
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
match_targets(targets, versions, resolved).any?
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# versions: is optional in a conditional entry; nil means unconstrained.
|
|
219
|
+
def malformed_requirements?(versions)
|
|
220
|
+
requirements = versions.is_a?(Hash) ? versions.values : [versions]
|
|
221
|
+
requirements.compact.any? do |req|
|
|
222
|
+
parts = Array(req).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
|
|
223
|
+
begin
|
|
224
|
+
Gem::Requirement.new(*parts)
|
|
225
|
+
false
|
|
226
|
+
rescue ArgumentError
|
|
227
|
+
true
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def render_support_templates(files, skill_dir, resolved:, warnings:)
|
|
233
|
+
plain_paths = files.map { |f| f[:path] }
|
|
234
|
+
files.filter_map do |file|
|
|
235
|
+
next file unless erb_template?(file[:path])
|
|
236
|
+
|
|
237
|
+
target = file[:path].delete_suffix(".erb")
|
|
238
|
+
if plain_paths.include?(target)
|
|
239
|
+
warnings << "skip #{File.join(skill_dir, file[:path])}: #{target} in the same directory takes precedence"
|
|
240
|
+
next nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
begin
|
|
244
|
+
{ path: target, body: SkillTemplate.render(file[:body], resolved: resolved) }
|
|
245
|
+
rescue SyntaxError, StandardError => e
|
|
246
|
+
warnings << "skip #{File.join(skill_dir, file[:path])}: ERB render failed (#{e.message})"
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def erb_template?(path)
|
|
253
|
+
path.end_with?(".md.erb")
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def install_ready_body(artifact)
|
|
257
|
+
return artifact.body if artifact.skill?
|
|
258
|
+
|
|
259
|
+
_frontmatter, rest = split_frontmatter(artifact.body)
|
|
260
|
+
(rest || artifact.body).sub(/\A\n+/, "")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def split_frontmatter(body)
|
|
264
|
+
lines = body.lines
|
|
265
|
+
return [nil, body] unless lines.first&.strip == "---"
|
|
266
|
+
|
|
267
|
+
closing_index = lines[1..].index { |l| l.strip == "---" }
|
|
268
|
+
return [nil, body] unless closing_index
|
|
269
|
+
|
|
270
|
+
absolute_closing = closing_index + 1
|
|
271
|
+
[lines[1...absolute_closing].join, lines[(absolute_closing + 1)..].join]
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def parse_targets(raw)
|
|
275
|
+
entries = raw.is_a?(Array) ? raw : [raw]
|
|
276
|
+
return nil if entries.any? { |e| e.nil? || e.is_a?(Array) || e.is_a?(Hash) }
|
|
277
|
+
entries.flat_map { |e| e.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def match_targets(targets, versions, resolved)
|
|
281
|
+
return ["*"] if targets.include?("*")
|
|
282
|
+
|
|
283
|
+
targets.select do |t|
|
|
284
|
+
version = resolved[t]
|
|
285
|
+
version && version_satisfied?(versions, t, version)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def version_satisfied?(versions, target, version)
|
|
290
|
+
requirement = versions.is_a?(Hash) ? versions[target] : versions
|
|
291
|
+
requirement.nil? || SkillTemplate.version_matches?(requirement, version)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def no_match_reason(targets, versions, resolved)
|
|
295
|
+
present = targets.select { |t| resolved.key?(t) }
|
|
296
|
+
if present.empty?
|
|
297
|
+
label = targets.size == 1 ? "target gem" : "target gems"
|
|
298
|
+
"#{label} '#{targets.join(", ")}' not in bundle"
|
|
299
|
+
else
|
|
300
|
+
present.map { |t| "#{t} #{resolved[t]} does not satisfy '#{versions.is_a?(Hash) ? versions[t] : versions}'" }
|
|
301
|
+
.join("; ")
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def safe_bundler_specs
|
|
306
|
+
::Bundler.load.specs.to_a
|
|
307
|
+
rescue ::Bundler::BundlerError
|
|
308
|
+
[]
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
private_class_method :each_artifact_path, :skill_paths, :guideline_paths,
|
|
312
|
+
:skills_dir_override, :parse, :support_files_for,
|
|
313
|
+
:conditioned_support_files, :apply_conditional_filter,
|
|
314
|
+
:conditional_satisfied?, :malformed_requirements?,
|
|
315
|
+
:render_support_templates, :erb_template?,
|
|
316
|
+
:split_frontmatter, :parse_targets, :match_targets,
|
|
317
|
+
:version_satisfied?, :no_match_reason, :safe_bundler_specs
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require "rails/hyperdrive/install_layout"
|
|
2
|
+
require "rails/hyperdrive/lock_file"
|
|
3
|
+
|
|
4
|
+
module Rails
|
|
5
|
+
module Hyperdrive
|
|
6
|
+
# The persistent opt-out state machine for the single import line in
|
|
7
|
+
# CLAUDE.md, as a pure function of the file's content, the recorded state,
|
|
8
|
+
# and the install mode. The caller reads, writes, and prints.
|
|
9
|
+
module ClaudeMdImport
|
|
10
|
+
PATH = "CLAUDE.md".freeze
|
|
11
|
+
INDEX_LINE = "@#{InstallLayout::INDEX_PATH}".freeze
|
|
12
|
+
|
|
13
|
+
NEW_FILE = "<!-- AI instructions for this project. Managed content lives in " \
|
|
14
|
+
"#{InstallLayout::HYPERDRIVE_DIR}/. -->\n\n#{INDEX_LINE}\n".freeze
|
|
15
|
+
APPENDED = "\n#{INDEX_LINE}\n".freeze
|
|
16
|
+
|
|
17
|
+
REMOVED_WARNING = "you removed #{INDEX_LINE} from #{PATH}; leaving it out (won't re-add)".freeze
|
|
18
|
+
|
|
19
|
+
# action is :none, :create, :append, :delete, or :rewrite; body is the
|
|
20
|
+
# file content for :create and :rewrite and the appended fragment for
|
|
21
|
+
# :append, nil otherwise.
|
|
22
|
+
Decision = Struct.new(:action, :body, :state, :warning, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# content is the current CLAUDE.md text, or nil when the file is absent.
|
|
27
|
+
def decide(content:, state:, mode:)
|
|
28
|
+
# CLAUDE.md is the user's own file; only a generator they ran edits it.
|
|
29
|
+
return Decision.new(action: :none, state: state) if mode == :additive
|
|
30
|
+
|
|
31
|
+
present = !content.nil? && content.include?(INDEX_LINE)
|
|
32
|
+
|
|
33
|
+
if state.nil?
|
|
34
|
+
return Decision.new(action: :create, body: NEW_FILE, state: LockFile::STATE_PRESENT) if content.nil?
|
|
35
|
+
return Decision.new(action: :none, state: LockFile::STATE_PRESENT) if present
|
|
36
|
+
|
|
37
|
+
Decision.new(action: :append, body: APPENDED, state: LockFile::STATE_PRESENT)
|
|
38
|
+
elsif state == LockFile::STATE_PRESENT && !present
|
|
39
|
+
Decision.new(action: :none, state: LockFile::STATE_REMOVED, warning: REMOVED_WARNING)
|
|
40
|
+
elsif state == LockFile::STATE_REMOVED && present
|
|
41
|
+
Decision.new(action: :none, state: LockFile::STATE_PRESENT)
|
|
42
|
+
else
|
|
43
|
+
Decision.new(action: :none, state: state)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Retracts the import line once nothing is left for it to pull in. A nil
|
|
48
|
+
# state means the machine is disarmed, so a later guideline-bearing run
|
|
49
|
+
# adds the line back.
|
|
50
|
+
def teardown(content:, state:)
|
|
51
|
+
# An opt-out the user made by hand outlives the line it removed.
|
|
52
|
+
return Decision.new(action: :none, state: LockFile::STATE_REMOVED) if state == LockFile::STATE_REMOVED
|
|
53
|
+
return Decision.new(action: :none, state: nil) if content.nil?
|
|
54
|
+
return Decision.new(action: :none, state: nil) unless content.include?(INDEX_LINE)
|
|
55
|
+
# Byte-identical to what we wrote proves nobody else owns the file.
|
|
56
|
+
return Decision.new(action: :delete, state: nil) if content == NEW_FILE
|
|
57
|
+
|
|
58
|
+
Decision.new(action: :rewrite, body: strip_index_line(content), state: nil)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def strip_index_line(content)
|
|
62
|
+
kept = content.lines.reject { |line| line.strip == INDEX_LINE }.join
|
|
63
|
+
kept.sub(/\n\n+\z/, "\n")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private_class_method :strip_index_line
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|