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,415 @@
1
+ require "time"
2
+ require "rails/hyperdrive/audit_header"
3
+ require "rails/hyperdrive/bundler_artifact_discovery"
4
+ require "rails/hyperdrive/claude_md_import"
5
+ require "rails/hyperdrive/drift_verdict"
6
+ require "rails/hyperdrive/eager_footprint"
7
+ require "rails/hyperdrive/index_document"
8
+ require "rails/hyperdrive/install_layout"
9
+ require "rails/hyperdrive/install_plan"
10
+ require "rails/hyperdrive/lock_file"
11
+
12
+ module Rails
13
+ module Hyperdrive
14
+ # The application root is explicit and no Rails constant is touched, so any
15
+ # process that can see the bundle can run this.
16
+ class InstallPipeline
17
+ ARTIFACT_DESTINATIONS =
18
+ [InstallLayout::SKILLS_DIR, InstallLayout::HYPERDRIVE_DIR, InstallLayout::LOCK_PATH].freeze
19
+
20
+ WARN_LINES = 150
21
+ WARN_TOKENS = 1_500
22
+
23
+ MODES = %i[preserve overwrite additive].freeze
24
+
25
+ Result = Struct.new(:installed, :updated, :unchanged, :skipped, :orphaned, :removed, keyword_init: true)
26
+
27
+ def initialize(root:, shell:, artifacts:, mode: :preserve, warnings: [])
28
+ raise ArgumentError, "unknown mode #{mode.inspect}" unless MODES.include?(mode)
29
+
30
+ @root = File.expand_path(root.to_s)
31
+ @shell = shell
32
+ @artifacts = artifacts
33
+ @mode = mode
34
+ @warnings = warnings
35
+ @result = Result.new(installed: [], updated: [], unchanged: [], skipped: [], orphaned: [], removed: [])
36
+ end
37
+
38
+ def call
39
+ @new_lock = LockFile.new(abs(InstallLayout::LOCK_PATH)).carry_settings(old_lock)
40
+ @plan = plan
41
+
42
+ report_collisions
43
+ report_disabled
44
+ install_skills
45
+ guidelines = install_guidelines
46
+ remove_disabled
47
+ remove_stale_support_files
48
+ carry_orphans
49
+ eager_guidelines = write_index_md(guidelines)
50
+ write_claude_md(guidelines)
51
+ write_lock
52
+ print_warnings
53
+ print_footprint(guidelines: eager_guidelines)
54
+ warn_if_destinations_gitignored
55
+ @result
56
+ end
57
+
58
+ def plan
59
+ build_result.entries
60
+ end
61
+
62
+ def lock
63
+ @new_lock
64
+ end
65
+
66
+ private
67
+
68
+ def build_result
69
+ @build_result ||= InstallPlan.build(@artifacts, lock: old_lock)
70
+ end
71
+
72
+ def old_lock
73
+ @old_lock ||= LockFile.load(abs(InstallLayout::LOCK_PATH))
74
+ end
75
+
76
+ def additive?
77
+ @mode == :additive
78
+ end
79
+
80
+ def overwrite_mode?
81
+ @mode == :overwrite
82
+ end
83
+
84
+ def abs(path)
85
+ File.join(@root, path)
86
+ end
87
+
88
+ def report_collisions
89
+ @plan.group_by { |e| [e.type, e.artifact.name] }.each do |(type, name), group|
90
+ next unless group.first.collision
91
+ @shell.say_status :conflict,
92
+ "#{type} '#{name}' shipped by #{group.map { |e| e.artifact.source_gem }.join(", ")}; installing all (postfixed)",
93
+ :yellow
94
+ end
95
+ end
96
+
97
+ def report_disabled
98
+ build_result.disabled.each do |entry|
99
+ @shell.say_status :disabled, "#{entry.type} '#{entry.final_name}' (listed in #{InstallLayout::LOCK_PATH})", :blue
100
+ end
101
+ end
102
+
103
+ def install_skills
104
+ @plan.select { |e| e.type == :skill }.each do |entry|
105
+ install_file(entry: entry, type: :skill, install_ready_body: entry.install_ready_body)
106
+ entry.support_files.each do |file|
107
+ install_file(
108
+ dest: file[:dest],
109
+ type: :skill_support,
110
+ install_ready_body: file[:body],
111
+ source_gem: entry.source_gem,
112
+ version: entry.version,
113
+ artifact_kind: "skill_support"
114
+ )
115
+ end
116
+ end
117
+ end
118
+
119
+ def install_guidelines
120
+ @plan.select { |e| e.type == :guideline }.map do |entry|
121
+ body = entry.install_ready_body
122
+ warn_if_oversize(entry.dest, body)
123
+ install_file(entry: entry, type: :guideline, install_ready_body: body)
124
+ { base: "#{entry.final_name}.md", dest: entry.dest, body: body }
125
+ end
126
+ end
127
+
128
+ def install_file(dest: nil, type:, install_ready_body:, entry: nil, source_gem: nil, version: nil, artifact_kind: nil)
129
+ write = {
130
+ dest: dest || entry.dest,
131
+ type: type,
132
+ body: install_ready_body,
133
+ source_gem: source_gem || entry.source_gem,
134
+ version: version || entry.version,
135
+ gem_sha: DriftVerdict.body_sha(install_ready_body),
136
+ artifact_kind: artifact_kind || entry.artifact_kind
137
+ }
138
+ old = old_lock.entry(write[:dest])
139
+
140
+ return additive_install(write, old) if additive?
141
+
142
+ file = abs(write[:dest])
143
+ case DriftVerdict.verdict(file: file, kind: write[:artifact_kind], lock_entry: old, gem_sha: write[:gem_sha])
144
+ when :missing
145
+ @shell.say_status(:reinstall, "#{write[:dest]} (was missing)", :yellow) if old
146
+ write_artifact(**write)
147
+ when :current
148
+ @new_lock.carry(old)
149
+ @result.unchanged << write[:dest]
150
+ @shell.say_status :unchanged, write[:dest], :blue
151
+ when :outdated
152
+ write_artifact(**write)
153
+ when :edited
154
+ if overwrite_mode?
155
+ write_artifact(**write)
156
+ else
157
+ @result.skipped << write[:dest]
158
+ @shell.say_status :skip, "#{write[:dest]} (locally modified; run hyperdrive:sync --overwrite to overwrite)", :yellow
159
+ @new_lock.carry(old) if old
160
+ end
161
+ end
162
+ end
163
+
164
+ # Additive installs can create a file and never overwrite one, so an
165
+ # artifact the user edited — or deleted on purpose — survives.
166
+ def additive_install(write, old)
167
+ if old
168
+ @new_lock.carry(old)
169
+ (old.source_sha == write[:gem_sha] ? @result.unchanged : @result.skipped) << write[:dest]
170
+ elsif File.exist?(abs(write[:dest]))
171
+ @result.skipped << write[:dest]
172
+ else
173
+ write_artifact(**write)
174
+ end
175
+ end
176
+
177
+ # Supporting files land byte-identical to what the gem ships — no audit
178
+ # header, since they may be non-markdown or binary; provenance and sha
179
+ # live in the lock alone.
180
+ def write_artifact(dest:, type:, body:, source_gem:, version:, gem_sha:, artifact_kind:)
181
+ installed_at = Time.now.utc
182
+ header_args = { source_gem: source_gem, version: version, sha256: gem_sha, installed_at: installed_at }
183
+ on_disk =
184
+ case type
185
+ when :skill
186
+ AuditHeader.inject_into_frontmatter(body, AuditHeader.build(**header_args))
187
+ when :skill_support
188
+ body
189
+ else
190
+ AuditHeader.prepend_html(body, AuditHeader.build_html(**header_args))
191
+ end
192
+
193
+ existed = old_lock.entry(dest)
194
+ @shell.create_file dest, on_disk
195
+ (existed ? @result.updated : @result.installed) << dest
196
+ @new_lock.upsert(
197
+ path: dest,
198
+ kind: artifact_kind,
199
+ source_gem: source_gem,
200
+ source_version: version,
201
+ source_sha: gem_sha,
202
+ installed_at: installed_at.iso8601
203
+ )
204
+ end
205
+
206
+ # The pipeline's only delete path, so it is gated on the file still
207
+ # matching the content the lock recorded: hand-edited work is reported and
208
+ # left for its owner to remove. Runs before orphans are carried, or a
209
+ # removed file would read as one.
210
+ def remove_disabled
211
+ return if additive?
212
+
213
+ old_lock.each_entry do |entry|
214
+ type = InstallLayout::ARTIFACT_TYPES[entry.kind]
215
+ next unless type
216
+ next if @new_lock.entry(entry.path)
217
+
218
+ next unless InstallPlan.disabled_dest?(old_lock, type, entry.path)
219
+
220
+ file = abs(entry.path)
221
+ next unless File.exist?(file)
222
+
223
+ if DriftVerdict.unedited?(file, lock_entry: entry)
224
+ @shell.remove_file entry.path
225
+ @result.removed << entry.path
226
+ prune_empty_dirs(entry.path)
227
+ else
228
+ @result.skipped << entry.path
229
+ @shell.say_status :skip,
230
+ "#{entry.path} (disabled but locally modified; delete it by hand)", :yellow
231
+ @new_lock.carry(entry)
232
+ end
233
+ end
234
+ end
235
+
236
+ # Deletion is gated on the recorded sha, so a user-edited copy is never
237
+ # removed.
238
+ def remove_stale_support_files
239
+ return if additive?
240
+
241
+ planned_skill_dirs = @plan.select { |e| e.type == :skill }.map { |e| File.dirname(e.dest) }
242
+
243
+ old_lock.each_entry do |entry|
244
+ next unless entry.kind == "skill_support"
245
+ next if @new_lock.entry(entry.path)
246
+ next unless planned_skill_dirs.include?(InstallLayout.skill_dir_of(entry.path))
247
+
248
+ file = abs(entry.path)
249
+ next unless File.exist?(file)
250
+
251
+ if DriftVerdict.unedited?(file, lock_entry: entry)
252
+ @shell.remove_file entry.path
253
+ @result.removed << entry.path
254
+ prune_empty_dirs(entry.path)
255
+ else
256
+ @result.skipped << entry.path
257
+ @shell.say_status :skip,
258
+ "#{entry.path} (no longer shipped by #{entry.source_label} but locally modified; delete it by hand)",
259
+ :yellow
260
+ @new_lock.carry(entry)
261
+ end
262
+ end
263
+ end
264
+
265
+ # Skill directories go only once empty; any user file keeps the whole
266
+ # chain alive. The just-removed entry is subtracted because a dry run
267
+ # deletes nothing from disk.
268
+ def prune_empty_dirs(removed_dest)
269
+ removed = abs(removed_dest)
270
+ dir = File.dirname(removed_dest)
271
+ while dir.start_with?("#{InstallLayout::SKILLS_DIR}/")
272
+ children = Dir.exist?(abs(dir)) ? Dir.children(abs(dir)).map { |c| File.join(abs(dir), c) } : []
273
+ children -= [removed]
274
+ break unless children.empty?
275
+
276
+ @shell.remove_file dir
277
+ removed = abs(dir)
278
+ dir = File.dirname(dir)
279
+ end
280
+ end
281
+
282
+ def carry_orphans
283
+ planned = @plan.map(&:dest)
284
+ old_lock.each_entry do |entry|
285
+ next if planned.include?(entry.path)
286
+ next if @new_lock.entry(entry.path)
287
+ next unless DriftVerdict.verdict(file: abs(entry.path), kind: entry.kind, lock_entry: entry, gem_sha: nil) == :orphaned
288
+
289
+ @result.orphaned << entry.path
290
+ @shell.say_status :orphan,
291
+ "#{entry.path} (source #{entry.source_label} no longer in bundle; left in place)", :yellow
292
+ @new_lock.carry(entry)
293
+ end
294
+ end
295
+
296
+ # Returns the eager set: the guidelines index.md ends up including.
297
+ def write_index_md(guidelines)
298
+ return amend_index_md(guidelines) if additive?
299
+ return teardown_index_md if guidelines.empty?
300
+
301
+ existing = read_index
302
+ rendered = IndexDocument.render(
303
+ guidelines: guidelines,
304
+ existing: existing,
305
+ previously_installed: old_lock.guideline_paths.map { |p| File.basename(p) }
306
+ )
307
+
308
+ if existing == rendered.content
309
+ @shell.say_status :unchanged, InstallLayout::INDEX_PATH, :blue
310
+ else
311
+ @shell.create_file InstallLayout::INDEX_PATH, rendered.content
312
+ end
313
+ rendered.guidelines
314
+ end
315
+
316
+ def amend_index_md(guidelines)
317
+ added = guidelines.select { |g| @result.installed.include?(g[:dest]) }.map { |g| g[:base] }
318
+ content = IndexDocument.amend(existing: read_index, added: added)
319
+ @shell.create_file InstallLayout::INDEX_PATH, content if content
320
+ [] # an additive run prints no footprint, so it reports no eager set
321
+ end
322
+
323
+ # No sha gate: the file has no lock entry, and the only user input it
324
+ # carries is a deleted `@`-line, which is moot once no guideline is
325
+ # planned.
326
+ def teardown_index_md
327
+ if File.exist?(abs(InstallLayout::INDEX_PATH))
328
+ @shell.remove_file InstallLayout::INDEX_PATH
329
+ @result.removed << InstallLayout::INDEX_PATH
330
+ end
331
+ []
332
+ end
333
+
334
+ def read_index
335
+ file = abs(InstallLayout::INDEX_PATH)
336
+ File.read(file) if File.exist?(file)
337
+ end
338
+
339
+ def write_claude_md(guidelines)
340
+ file = abs(ClaudeMdImport::PATH)
341
+ content = File.read(file) if File.exist?(file)
342
+ decision =
343
+ if guidelines.empty? && !additive?
344
+ ClaudeMdImport.teardown(content: content, state: old_lock.claude_md_state)
345
+ else
346
+ ClaudeMdImport.decide(content: content, state: old_lock.claude_md_state, mode: @mode)
347
+ end
348
+
349
+ case decision.action
350
+ when :create then @shell.create_file ClaudeMdImport::PATH, decision.body
351
+ when :append then @shell.append_to_file ClaudeMdImport::PATH, decision.body
352
+ when :rewrite then @shell.create_file ClaudeMdImport::PATH, decision.body
353
+ when :delete then @shell.remove_file ClaudeMdImport::PATH
354
+ end
355
+ @shell.say_status :warn, decision.warning, :yellow if decision.warning
356
+
357
+ @new_lock.claude_md_state = decision.state
358
+ end
359
+
360
+ def write_lock
361
+ @shell.create_file InstallLayout::LOCK_PATH, @new_lock.to_yaml
362
+ end
363
+
364
+ def print_warnings
365
+ return if Array(@warnings).empty?
366
+ @shell.say ""
367
+ @shell.say_status :warn, "discovery skipped #{@warnings.size} artifact(s):", :yellow
368
+ @warnings.each { |w| @shell.say " - #{w}" }
369
+ end
370
+
371
+ def print_footprint(guidelines:)
372
+ return if additive?
373
+
374
+ EagerFootprint.lines(guidelines: guidelines).each do |line|
375
+ @shell.say_status line.status, line.message, line.color
376
+ end
377
+ end
378
+
379
+ def warn_if_destinations_gitignored
380
+ # An additive run has no terminal to warn at; it reports through its result.
381
+ return if additive?
382
+ ignored = gitignored_destinations
383
+ return if ignored.empty?
384
+
385
+ @shell.say_status :warn,
386
+ "#{ignored.join(", ")} #{ignored.one? ? "is" : "are"} gitignored; installed artifacts stay out of " \
387
+ "git status and pull-request diffs, so companion-shipped content reaches the agent unreviewed",
388
+ :yellow
389
+ end
390
+
391
+ # Patterns, negations, and per-repository excludes all decide ignore
392
+ # status, so git is the only correct oracle. Fail-open: no match, no
393
+ # repository, and no git alike exit non-zero and read as "not ignored".
394
+ def gitignored_destinations
395
+ output = IO.popen(
396
+ ["git", "check-ignore", "--", *ARTIFACT_DESTINATIONS],
397
+ chdir: @root, err: File::NULL, &:read
398
+ )
399
+ return [] unless $?&.success?
400
+ output.to_s.split("\n").map(&:strip).reject(&:empty?)
401
+ rescue SystemCallError
402
+ []
403
+ end
404
+
405
+ def warn_if_oversize(dest, body)
406
+ lines = body.lines.size
407
+ tokens = EagerFootprint.approx_tokens(body)
408
+ return unless lines > WARN_LINES || tokens > WARN_TOKENS
409
+ @shell.say_status :warn,
410
+ "#{dest} is large (#{lines} lines, ~#{tokens} tokens); guidelines are eager — move tutorial content to a skill",
411
+ :yellow
412
+ end
413
+ end
414
+ end
415
+ end
@@ -0,0 +1,78 @@
1
+ require "rails/hyperdrive/bundler_artifact_discovery"
2
+ require "rails/hyperdrive/install_layout"
3
+
4
+ module Rails
5
+ module Hyperdrive
6
+ # A name shipped by one source installs at its canonical path; a name
7
+ # shipped by several installs every variant, postfixed --<source_gem>.
8
+ module InstallPlan
9
+ Entry = Struct.new(:type, :artifact, :final_name, :dest, :collision, keyword_init: true) do
10
+ # The renamed name: is part of the hashed body, so a postfixed skill
11
+ # hashes to a stable value.
12
+ def install_ready_body
13
+ body = BundlerArtifactDiscovery.install_ready_body(artifact)
14
+ return body unless type == :skill && final_name != artifact.name
15
+ body.sub(/^name:\s*.+$/, "name: #{final_name}")
16
+ end
17
+
18
+ def support_files
19
+ return [] unless type == :skill
20
+ dir = File.dirname(dest)
21
+ Array(artifact.support_files).map do |file|
22
+ { path: file[:path], body: file[:body], dest: "#{dir}/#{file[:path]}" }
23
+ end
24
+ end
25
+
26
+ def source_gem
27
+ artifact.source_gem
28
+ end
29
+
30
+ def version
31
+ artifact.spec_version
32
+ end
33
+
34
+ def source_label
35
+ "#{source_gem}@#{version}"
36
+ end
37
+
38
+ def artifact_kind
39
+ type.to_s
40
+ end
41
+ end
42
+
43
+ Result = Struct.new(:entries, :disabled, keyword_init: true)
44
+
45
+ module_function
46
+
47
+ # A collision installs under a postfixed name, so the shipped name
48
+ # disables every variant and a postfixed name disables just one.
49
+ def build(artifacts, lock: nil)
50
+ result = Result.new(entries: [], disabled: [])
51
+ artifacts.group_by { |a| [a.artifact_type, a.name] }.each do |(type, name), group|
52
+ collision = group.size > 1
53
+ group.each do |artifact|
54
+ final_name = collision ? InstallLayout.postfixed_name(name, artifact.source_gem) : name
55
+ entry = Entry.new(
56
+ type: type,
57
+ artifact: artifact,
58
+ final_name: final_name,
59
+ dest: InstallLayout.dest_for(type, final_name),
60
+ collision: collision
61
+ )
62
+ dropped = lock && (lock.disabled?(type, name) || (collision && lock.disabled?(type, final_name)))
63
+ (dropped ? result.disabled : result.entries) << entry
64
+ end
65
+ end
66
+ result
67
+ end
68
+
69
+ # A supporting file is disabled through its owning skill's name.
70
+ def disabled_dest?(lock, type, dest)
71
+ name = InstallLayout.installed_name(type, dest)
72
+ return false unless name
73
+ lookup = type == :skill_support ? :skill : type
74
+ lock.disabled?(lookup, name) || lock.disabled?(lookup, InstallLayout.base_name(name))
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,43 @@
1
+ require "fileutils"
2
+
3
+ module Rails
4
+ module Hyperdrive
5
+ class InstallShell
6
+ attr_reader :root
7
+
8
+ def initialize(root:, io: nil, pretend: false)
9
+ @root = File.expand_path(root.to_s)
10
+ @io = io
11
+ @pretend = pretend
12
+ end
13
+
14
+ def create_file(path, content)
15
+ abs = File.join(@root, path)
16
+ unless @pretend
17
+ FileUtils.mkdir_p(File.dirname(abs))
18
+ File.write(abs, content)
19
+ end
20
+ say_status(:create, path)
21
+ end
22
+
23
+ def remove_file(path)
24
+ FileUtils.rm_rf(File.join(@root, path)) unless @pretend
25
+ say_status(:remove, path)
26
+ end
27
+
28
+ def append_to_file(path, content)
29
+ abs = File.join(@root, path)
30
+ File.open(abs, "a") { |f| f.write(content) } unless @pretend
31
+ say_status(:append, path)
32
+ end
33
+
34
+ def say_status(kind, message, _color = nil)
35
+ @io&.puts(format("%12s %s", kind, message))
36
+ end
37
+
38
+ def say(message = "")
39
+ @io&.puts(message)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,171 @@
1
+ require "yaml"
2
+
3
+ module Rails
4
+ module Hyperdrive
5
+ # installed_at is volatile metadata, never an input to any comparison.
6
+ class LockFile
7
+ SCHEMA_VERSION = 1
8
+ STATE_PRESENT = "present".freeze
9
+ STATE_REMOVED = "removed-by-user".freeze
10
+
11
+ DISABLED_KEYS = { skill: "skills", guideline: "guidelines" }.freeze
12
+
13
+ # In-memory form of one files: entry. On disk, source_gem and
14
+ # source_version are a single "gem@version" string; the split/join lives
15
+ # in this file only.
16
+ Entry = Struct.new(:path, :kind, :source_gem, :source_version, :source_sha, :installed_at, keyword_init: true) do
17
+ def source_label
18
+ source_version ? "#{source_gem}@#{source_version}" : source_gem
19
+ end
20
+ end
21
+
22
+ attr_reader :path
23
+ attr_accessor :claude_md_state
24
+
25
+ # Load existing lock state from disk (absent file → empty lock).
26
+ def self.load(path)
27
+ new(path).tap(&:read)
28
+ end
29
+
30
+ def initialize(path)
31
+ @path = path.to_s
32
+ @claude_md_state = nil # nil = no lock has been written yet
33
+ @files = {} # path(String) => Entry
34
+ @document = {} # raw parsed YAML, kept so unknown keys survive
35
+ @disabled = empty_disabled
36
+ end
37
+
38
+ def read
39
+ return self unless File.exist?(@path)
40
+
41
+ data = YAML.safe_load(File.read(@path))
42
+ return self unless data.is_a?(Hash)
43
+
44
+ @document = data
45
+ claude_md = data["claude_md"]
46
+ @claude_md_state = claude_md["state"] if claude_md.is_a?(Hash)
47
+ @disabled = parse_disabled(data["disabled"])
48
+ Array(data["files"]).each do |raw|
49
+ next unless raw.is_a?(Hash)
50
+ entry = build_entry(raw)
51
+ @files[entry.path] = entry if entry.path
52
+ end
53
+ self
54
+ rescue Psych::SyntaxError
55
+ self
56
+ end
57
+
58
+ def entry(file_path)
59
+ @files[file_path.to_s]
60
+ end
61
+
62
+ def guideline_paths
63
+ @files.values.select { |e| e.kind == "guideline" }.map(&:path)
64
+ end
65
+
66
+ def each_entry(&block)
67
+ @files.values.each(&block)
68
+ end
69
+
70
+ def disabled?(type, name)
71
+ Array(@disabled[type.to_sym]).include?(name.to_s)
72
+ end
73
+
74
+ # Adopt the state that is not derived from installed content, so
75
+ # rewriting the file preserves it.
76
+ def carry_settings(other)
77
+ @document = other.document.dup
78
+ @disabled = other.disabled_lists.dup
79
+ self
80
+ end
81
+
82
+ def upsert(path:, kind:, source_gem:, source_version:, source_sha:, installed_at:)
83
+ @files[path.to_s] = Entry.new(
84
+ path: path.to_s,
85
+ kind: kind.to_s,
86
+ source_gem: source_gem.to_s,
87
+ source_version: source_version.to_s,
88
+ source_sha: source_sha.to_s,
89
+ installed_at: installed_at.to_s
90
+ )
91
+ end
92
+
93
+ def carry(entry)
94
+ return unless entry && entry.path
95
+ @files[entry.path] = entry
96
+ end
97
+
98
+ def to_yaml
99
+ carried = @document["claude_md"]
100
+ carried = {} unless carried.is_a?(Hash)
101
+
102
+ document = @document.merge(
103
+ "version" => SCHEMA_VERSION,
104
+ "claude_md" => carried.merge("state" => @claude_md_state),
105
+ "disabled" => DISABLED_KEYS.each_with_object({}) { |(type, key), h| h[key] = @disabled[type] },
106
+ "files" => @files.values.sort_by(&:path).map { |e| serialize_entry(e) }
107
+ )
108
+ # A nil state means no import line is being managed. Recording one anyway
109
+ # would make the next run read the absent line as a deletion the user
110
+ # made, and never add it back.
111
+ document.delete("claude_md") if @claude_md_state.nil?
112
+ document.to_yaml
113
+ end
114
+
115
+ protected
116
+
117
+ def document
118
+ @document
119
+ end
120
+
121
+ def disabled_lists
122
+ @disabled
123
+ end
124
+
125
+ private
126
+
127
+ def empty_disabled
128
+ DISABLED_KEYS.keys.each_with_object({}) { |type, h| h[type] = [] }
129
+ end
130
+
131
+ def parse_disabled(raw)
132
+ return empty_disabled unless raw.is_a?(Hash)
133
+
134
+ DISABLED_KEYS.each_with_object({}) do |(type, key), h|
135
+ h[type] = Array(raw[key]).map { |name| name.to_s.strip }.reject(&:empty?).uniq
136
+ end
137
+ end
138
+
139
+ def build_entry(raw)
140
+ source_gem, source_version = split_source(raw["source"])
141
+ Entry.new(
142
+ path: raw["path"],
143
+ kind: raw["artifact"],
144
+ source_gem: source_gem,
145
+ source_version: source_version,
146
+ source_sha: raw["source_sha"],
147
+ installed_at: raw["installed_at"]
148
+ )
149
+ end
150
+
151
+ # A source with no "@" (hand-edited) keeps its whole value as the gem
152
+ # part with a nil version, so serialization re-emits it unchanged.
153
+ def split_source(source)
154
+ return [nil, nil] if source.nil?
155
+ source = source.to_s
156
+ idx = source.index("@")
157
+ idx ? [source[0...idx], source[(idx + 1)..]] : [source, nil]
158
+ end
159
+
160
+ def serialize_entry(entry)
161
+ {
162
+ "path" => entry.path,
163
+ "artifact" => entry.kind,
164
+ "source" => entry.source_label,
165
+ "source_sha" => entry.source_sha,
166
+ "installed_at" => entry.installed_at
167
+ }
168
+ end
169
+ end
170
+ end
171
+ end