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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +295 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +229 -0
  5. data/Rakefile +6 -0
  6. data/SECURITY.md +43 -0
  7. data/config/routes.rb +5 -0
  8. data/lib/generators/hyperdrive/content_sync_support.rb +54 -0
  9. data/lib/generators/hyperdrive/discover/discover_generator.rb +103 -0
  10. data/lib/generators/hyperdrive/gitignore_support.rb +28 -0
  11. data/lib/generators/hyperdrive/install/USAGE +22 -0
  12. data/lib/generators/hyperdrive/install/install_generator.rb +168 -0
  13. data/lib/generators/hyperdrive/install/templates/initializer.rb.tt +3 -0
  14. data/lib/generators/hyperdrive/install_summary.rb +70 -0
  15. data/lib/generators/hyperdrive/sync/USAGE +21 -0
  16. data/lib/generators/hyperdrive/sync/sync_generator.rb +40 -0
  17. data/lib/generators/hyperdrive/sync_runner.rb +70 -0
  18. data/lib/rails/hyperdrive/artifact_status.rb +86 -0
  19. data/lib/rails/hyperdrive/audit_header.rb +83 -0
  20. data/lib/rails/hyperdrive/auto_install.rb +98 -0
  21. data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +320 -0
  22. data/lib/rails/hyperdrive/claude_md_import.rb +69 -0
  23. data/lib/rails/hyperdrive/companion_discovery.rb +232 -0
  24. data/lib/rails/hyperdrive/console_executor.rb +64 -0
  25. data/lib/rails/hyperdrive/data/gem_categories.yml +52 -0
  26. data/lib/rails/hyperdrive/drift_verdict.rb +42 -0
  27. data/lib/rails/hyperdrive/eager_footprint.rb +48 -0
  28. data/lib/rails/hyperdrive/engine.rb +22 -0
  29. data/lib/rails/hyperdrive/index_document.rb +49 -0
  30. data/lib/rails/hyperdrive/install_layout.rb +45 -0
  31. data/lib/rails/hyperdrive/install_pipeline.rb +415 -0
  32. data/lib/rails/hyperdrive/install_plan.rb +78 -0
  33. data/lib/rails/hyperdrive/install_shell.rb +43 -0
  34. data/lib/rails/hyperdrive/lock_file.rb +171 -0
  35. data/lib/rails/hyperdrive/mcp_server.rb +80 -0
  36. data/lib/rails/hyperdrive/resources/skill.rb +63 -0
  37. data/lib/rails/hyperdrive/resources/stack_profile.rb +32 -0
  38. data/lib/rails/hyperdrive/safety/rack_middleware.rb +54 -0
  39. data/lib/rails/hyperdrive/skill_template.rb +52 -0
  40. data/lib/rails/hyperdrive/sql_safety.rb +28 -0
  41. data/lib/rails/hyperdrive/stack_profile.rb +176 -0
  42. data/lib/rails/hyperdrive/tools/base.rb +39 -0
  43. data/lib/rails/hyperdrive/tools/describe_app.rb +21 -0
  44. data/lib/rails/hyperdrive/tools/list_models.rb +76 -0
  45. data/lib/rails/hyperdrive/tools/list_routes.rb +33 -0
  46. data/lib/rails/hyperdrive/tools/locate_source.rb +86 -0
  47. data/lib/rails/hyperdrive/tools/lookup_doc.rb +60 -0
  48. data/lib/rails/hyperdrive/tools/run_ruby.rb +31 -0
  49. data/lib/rails/hyperdrive/tools/run_sql.rb +49 -0
  50. data/lib/rails/hyperdrive/tools/tail_logs.rb +65 -0
  51. data/lib/rails/hyperdrive/version.rb +5 -0
  52. data/lib/rails/hyperdrive.rb +37 -0
  53. data/lib/rails-hyperdrive.rb +2 -0
  54. data/lib/tasks/hyperdrive.rake +22 -0
  55. 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