rails-hyperdrive 0.7.0 → 0.9.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.
@@ -1,9 +1,11 @@
1
1
  require "thor"
2
2
  require "rails/hyperdrive"
3
3
  require "rails/hyperdrive/bundler_artifact_discovery"
4
+ require "rails/hyperdrive/config_file"
4
5
  require "rails/hyperdrive/install_layout"
5
6
  require "rails/hyperdrive/install_pipeline"
6
7
  require "rails/hyperdrive/lock_file"
8
+ require "rails/hyperdrive/sidecar_resolver"
7
9
  require "generators/hyperdrive/install_summary"
8
10
 
9
11
  module Rails
@@ -13,6 +15,16 @@ module Rails
13
15
  # `install` forces the ones it needs, so no call order can install with a
14
16
  # half-built input set.
15
17
  class SyncRunner
18
+ RESOLVER_HELP = <<~MSG.freeze
19
+ --resolve needs a resolver command; add one to %<config>s:
20
+
21
+ resolve:
22
+ command: <your tool> $PROMPT
23
+
24
+ Placeholders: $LOCAL $REMOTE $BASE $MERGED $SOURCE $PREVIOUS_SOURCE $KIND $PROMPT
25
+ (also exported as HYPERDRIVE_* environment variables). Exit 0 marks the sidecar resolved.
26
+ MSG
27
+
16
28
  def initialize(shell:, root: nil)
17
29
  @shell = shell
18
30
  @root = root&.to_s
@@ -43,17 +55,58 @@ module Rails
43
55
  shell: @shell,
44
56
  artifacts: discover_artifacts,
45
57
  mode: mode,
46
- report: report
58
+ report: report,
59
+ config: config
47
60
  )
48
- @pipeline.call
61
+ @result = @pipeline.call
62
+ end
63
+
64
+ # Raised before any content write, so --resolve with nothing to run
65
+ # fails without half-syncing the app.
66
+ def verify_resolver!
67
+ return if config.resolve_command
68
+
69
+ raise Thor::Error,
70
+ "hyperdrive: " + format(RESOLVER_HELP, config: ::Rails::Hyperdrive::InstallLayout::CONFIG_PATH)
71
+ end
72
+
73
+ def resolve_sidecars(dry_run: false)
74
+ @resolve_outcome = ::Rails::Hyperdrive::SidecarResolver.new(
75
+ root: root,
76
+ shell: @shell,
77
+ command: config.resolve_command,
78
+ lock: @pipeline.lock,
79
+ sidecars: Array(@result&.sidecars),
80
+ prompt_path: config.resolve_prompt,
81
+ dry_run: dry_run
82
+ ).call
49
83
  end
50
84
 
51
85
  def summary_lines
52
- InstallSummary.lines(lock_entries)
86
+ InstallSummary.lines(lock_entries) + merge_lines + resolve_lines
53
87
  end
54
88
 
55
89
  private
56
90
 
91
+ def merge_lines
92
+ count = Array(@result&.merged).size
93
+ return [] if count.zero?
94
+
95
+ ["", " Merged #{InstallSummary.quantify(count, "file")} by three-way merge; a clean merge is " \
96
+ "textually non-overlapping, not verified; review with `git diff`"]
97
+ end
98
+
99
+ def resolve_lines
100
+ outcome = @resolve_outcome
101
+ return [] unless outcome
102
+
103
+ parts = { "resolved" => outcome.resolved.size, "unresolved" => outcome.unresolved.size,
104
+ "skipped" => outcome.skipped.size }.reject { |_label, count| count.zero? }
105
+ return ["", " No sidecars to resolve"] if parts.empty?
106
+
107
+ ["", " Sidecars: #{parts.map { |label, count| "#{count} #{label}" }.join(", ")}"]
108
+ end
109
+
57
110
  # Raised from install, so it stops the run before any content write —
58
111
  # a dry run included.
59
112
  def verify_lock_schema!
@@ -80,8 +133,14 @@ module Rails
80
133
  )
81
134
  end
82
135
 
136
+ def config
137
+ @config ||= ::Rails::Hyperdrive::ConfigFile.load(
138
+ File.join(root, ::Rails::Hyperdrive::InstallLayout::CONFIG_PATH)
139
+ )
140
+ end
141
+
83
142
  def enabled_gems
84
- lock.enabled_gems
143
+ config.enabled_gems
85
144
  end
86
145
 
87
146
  def lock_entries
@@ -2,6 +2,7 @@ require "bundler"
2
2
  require "rails/hyperdrive/bundler_artifact_discovery"
3
3
  require "rails/hyperdrive/drift_verdict"
4
4
  require "rails/hyperdrive/install_layout"
5
+ require "rails/hyperdrive/lock_file"
5
6
  require "rails/hyperdrive/skill_template"
6
7
 
7
8
  module Rails
@@ -34,6 +35,40 @@ module Rails
34
35
  nil
35
36
  end
36
37
 
38
+ # The sha gate runs against the ancestor an entry records while its
39
+ # sidecar is pending, not against the entry's own source, which by then
40
+ # names the pending delivery.
41
+ def locate_recorded_ancestor(lock_entry, final_name: nil, gem_paths: Gem.path, resolved: nil)
42
+ return nil unless lock_entry&.ancestor?
43
+
44
+ locate(
45
+ kind: lock_entry.kind,
46
+ relpath: lock_entry.ancestor_relpath,
47
+ lock_entry: LockFile::Entry.new(
48
+ source_gem: lock_entry.ancestor_gem,
49
+ source_version: lock_entry.ancestor_version,
50
+ source_sha: lock_entry.ancestor_sha
51
+ ),
52
+ final_name: final_name || final_name_from_dest(lock_entry),
53
+ gem_paths: gem_paths,
54
+ resolved: resolved
55
+ )
56
+ rescue StandardError
57
+ nil
58
+ end
59
+
60
+ # Only the kinds whose frontmatter carries the name need one, and for
61
+ # those the dest was built from it.
62
+ def final_name_from_dest(lock_entry)
63
+ type = InstallLayout::ARTIFACT_TYPES[lock_entry.kind.to_s]
64
+ descriptor = type && InstallLayout.kind(type)
65
+ return nil unless descriptor&.collision_rewrites_name
66
+
67
+ descriptor.installed_name(lock_entry.path.to_s)
68
+ rescue StandardError
69
+ nil
70
+ end
71
+
37
72
  def read_candidate(gem_root, relpath, kind:, resolved:)
38
73
  exact = File.join(gem_root, relpath)
39
74
  if File.file?(exact)
@@ -67,7 +102,7 @@ module Rails
67
102
  nil
68
103
  end
69
104
 
70
- private_class_method :read_candidate, :install_ready, :resolved_bundle
105
+ private_class_method :final_name_from_dest, :read_candidate, :install_ready, :resolved_bundle
71
106
  end
72
107
  end
73
108
  end
@@ -1,3 +1,4 @@
1
+ require "rails/hyperdrive/config_file"
1
2
  require "rails/hyperdrive/drift_verdict"
2
3
  require "rails/hyperdrive/install_layout"
3
4
  require "rails/hyperdrive/install_plan"
@@ -28,16 +29,17 @@ module Rails
28
29
  end
29
30
  end
30
31
 
31
- def self.compare(root:, artifacts:, bundled_gems: [])
32
- new(root: root, artifacts: artifacts, bundled_gems: bundled_gems).tap(&:compare)
32
+ def self.compare(root:, artifacts:, bundled_gems: [], config: nil)
33
+ new(root: root, artifacts: artifacts, bundled_gems: bundled_gems, config: config).tap(&:compare)
33
34
  end
34
35
 
35
36
  attr_reader :entries
36
37
 
37
- def initialize(root:, artifacts:, bundled_gems: [])
38
+ def initialize(root:, artifacts:, bundled_gems: [], config: nil)
38
39
  @root = File.expand_path(root.to_s)
39
40
  @artifacts = artifacts
40
41
  @bundled_gems = Array(bundled_gems).map(&:to_s)
42
+ @config = config
41
43
  @entries = []
42
44
  end
43
45
 
@@ -45,7 +47,7 @@ module Rails
45
47
  lock = LockFile.load(File.join(@root, InstallLayout::LOCK_PATH))
46
48
  offered = {}
47
49
 
48
- InstallPlan.build(@artifacts, lock: lock).entries.each do |plan_entry|
50
+ InstallPlan.build(@artifacts, config: config).entries.each do |plan_entry|
49
51
  offered[plan_entry.dest] = [DriftVerdict.body_sha(plan_entry.install_ready_body), plan_entry.source_label, plan_entry.type]
50
52
  plan_entry.support_files.each do |file|
51
53
  offered[file[:dest]] = [DriftVerdict.body_sha(file[:body]), plan_entry.source_label, :skill_support]
@@ -71,7 +73,7 @@ module Rails
71
73
  # A disabled artifact left on disk was reported at install time; the
72
74
  # bundle still ships it, so it is not an orphan.
73
75
  type = InstallLayout::ARTIFACT_TYPES[locked.kind]
74
- next if type && InstallPlan.disabled_dest?(lock, type, locked.path, source_gem: locked.source_gem)
76
+ next if type && InstallPlan.disabled_dest?(config, type, locked.path, source_gem: locked.source_gem)
75
77
 
76
78
  @entries << Entry.new(
77
79
  path: locked.path, state: :orphaned, artifact: locked.kind&.to_sym,
@@ -90,6 +92,12 @@ module Rails
90
92
  def stale?
91
93
  !missing.empty? || !outdated.empty? || !orphaned.empty?
92
94
  end
95
+
96
+ private
97
+
98
+ def config
99
+ @config ||= ConfigFile.load(File.join(@root, InstallLayout::CONFIG_PATH))
100
+ end
93
101
  end
94
102
  end
95
103
  end
@@ -1,6 +1,7 @@
1
1
  require "bundler"
2
2
  require "rails/hyperdrive/artifact_status"
3
3
  require "rails/hyperdrive/bundler_artifact_discovery"
4
+ require "rails/hyperdrive/config_file"
4
5
  require "rails/hyperdrive/install_layout"
5
6
  require "rails/hyperdrive/install_pipeline"
6
7
  require "rails/hyperdrive/install_shell"
@@ -14,7 +15,7 @@ module Rails
14
15
  DEVELOPMENT = "development".freeze
15
16
 
16
17
  Result = Struct.new(:installed, :outdated, :orphaned, :unwired, :advisories, :fence_warnings,
17
- :halted, :skipped, :error, keyword_init: true) do
18
+ :config_warnings, :halted, :skipped, :error, keyword_init: true) do
18
19
  def ran?
19
20
  skipped.nil?
20
21
  end
@@ -27,7 +28,7 @@ module Rails
27
28
  return [] unless ran?
28
29
  return [halted] if halted
29
30
 
30
- lines = []
31
+ lines = Array(config_warnings).dup
31
32
  unless Array(installed).empty?
32
33
  lines << "installed #{installed.size} artifact(s):"
33
34
  installed.each { |path| lines << " #{path}" }
@@ -57,9 +58,14 @@ module Rails
57
58
  lock = LockFile.load(File.join(root, InstallLayout::LOCK_PATH))
58
59
  return halted(lock.schema_ahead_message(InstallLayout::LOCK_PATH)) if lock.schema_ahead?
59
60
 
61
+ config = ConfigFile.load(File.join(root, InstallLayout::CONFIG_PATH))
62
+ config_warnings = config.warnings.dup
63
+ config_warnings << lock.legacy_settings_message(InstallLayout::LOCK_PATH) if lock.legacy_settings?
64
+
60
65
  report = BundlerArtifactDiscovery::Report.new
61
- artifacts = BundlerArtifactDiscovery.discover(enabled_gems: lock.enabled_gems, report: report)
62
- status = ArtifactStatus.compare(root: root, artifacts: artifacts, bundled_gems: report.bundled_gems)
66
+ artifacts = BundlerArtifactDiscovery.discover(enabled_gems: config.enabled_gems, report: report)
67
+ status = ArtifactStatus.compare(root: root, artifacts: artifacts, bundled_gems: report.bundled_gems,
68
+ config: config)
63
69
 
64
70
  installed = []
65
71
  unwired = false
@@ -70,7 +76,8 @@ module Rails
70
76
  shell: InstallShell.new(root: root, io: io),
71
77
  artifacts: artifacts,
72
78
  mode: :additive,
73
- report: report
79
+ report: report,
80
+ config: config
74
81
  )
75
82
  installed = pipeline.call.installed
76
83
  # This runs from a bundle install, which must not edit CLAUDE.md, so a
@@ -80,7 +87,8 @@ module Rails
80
87
  end
81
88
 
82
89
  Result.new(installed: installed, outdated: status.outdated, orphaned: status.orphaned,
83
- unwired: unwired, advisories: report.advisories, fence_warnings: report.fence_warnings)
90
+ unwired: unwired, advisories: report.advisories, fence_warnings: report.fence_warnings,
91
+ config_warnings: config_warnings)
84
92
  rescue StandardError => e
85
93
  skip(:error, e)
86
94
  end
@@ -106,12 +114,13 @@ module Rails
106
114
 
107
115
  def skip(reason, error = nil)
108
116
  Result.new(installed: [], outdated: [], orphaned: [], advisories: [], fence_warnings: [],
109
- skipped: reason, error: error)
117
+ config_warnings: [], skipped: reason, error: error)
110
118
  end
111
119
 
112
120
  # A completed run that installed nothing and has a reason to print.
113
121
  def halted(reason)
114
- Result.new(installed: [], outdated: [], orphaned: [], advisories: [], fence_warnings: [], halted: reason)
122
+ Result.new(installed: [], outdated: [], orphaned: [], advisories: [], fence_warnings: [],
123
+ config_warnings: [], halted: reason)
115
124
  end
116
125
  end
117
126
  end
@@ -88,9 +88,7 @@ module Rails
88
88
  next
89
89
  end
90
90
  manifest = GemManifest.load(spec, warnings: report.warnings)
91
- seen = Hash.new { |h, k| h[k] = [] }
92
- each_artifact_path(spec, manifest: manifest, report: report) do |path, kind, support_root, key|
93
- seen[kind.type] << key
91
+ seen = each_artifact_path(spec, manifest: manifest, report: report) do |path, kind, support_root, key|
94
92
  gate = manifest.gate(kind.type, key)
95
93
  artifact = parse(path, source_spec: spec, kind: kind, resolved: resolved,
96
94
  report: report, support_root: support_root, gate: gate, key: key, manifest: manifest)
@@ -111,20 +109,27 @@ module Rails
111
109
  end
112
110
  end
113
111
 
114
- # Yields each candidate with its kind descriptor and its manifest join
115
- # key: a directory-shaped kind's relpath from its root, a flat kind's
116
- # filename. Keys are collected before parsing, so a candidate later
117
- # dropped (e.g. an ERB render failure) still counts as known to the
118
- # manifest.
112
+ # Yields each candidate with its kind descriptor and the manifest join key
113
+ # its gating is read under: a directory-shaped kind's relpath from its
114
+ # root, a flat kind's filename. Returns every key the manifest could have
115
+ # named a shipped artifact by, collected before parsing so a candidate
116
+ # later dropped (e.g. an ERB render failure) still counts as known.
119
117
  def each_artifact_path(spec, manifest:, report:)
118
+ seen = Hash.new { |h, k| h[k] = [] }
120
119
  InstallLayout.content_kinds.each do |kind|
121
120
  if kind.dir_shaped?
122
- skill_paths(spec, manifest: manifest, report: report)
123
- .each { |path, support_root, rel| yield path, kind, support_root, rel }
121
+ skill_paths(spec, manifest: manifest, report: report).each do |path, support_root, rel|
122
+ seen[kind.type] << rel
123
+ yield path, kind, support_root, rel
124
+ end
124
125
  else
125
- flat_paths(spec, kind, manifest: manifest).each { |p| yield p, kind, nil, File.basename(p) }
126
+ seen[kind.type].concat(flat_keys(spec, kind, manifest: manifest))
127
+ flat_paths(spec, kind, manifest: manifest, report: report).each do |path|
128
+ yield path, kind, nil, flat_gate_key(path, kind, manifest, spec, report)
129
+ end
126
130
  end
127
131
  end
132
+ seen
128
133
  end
129
134
 
130
135
  # The staleness signal for gating detached from content: an entry
@@ -212,14 +217,60 @@ module Rails
212
217
  "supporting *.md.erb templates; static supporting files ship in the paired content directory"
213
218
  end
214
219
 
215
- # One .md file per artifact, deduped by expanded path so overlapping roots
216
- # yield each file once.
217
- def flat_paths(spec, kind, manifest:)
220
+ # One file per artifact: everything shipped, minus the templates a static
221
+ # file already ships the rendered face of.
222
+ def flat_paths(spec, kind, manifest:, report:)
223
+ resolve_flat_ties(flat_candidates(spec, kind, manifest: manifest), report)
224
+ end
225
+
226
+ # Every filename a manifest may key one of the kind's artifacts by: the
227
+ # name as shipped, plus each template's rendered face. Templates the tie
228
+ # rule drops are included — the manifest names shipped files, not
229
+ # surviving ones.
230
+ def flat_keys(spec, kind, manifest:)
231
+ flat_candidates(spec, kind, manifest: manifest)
232
+ .flat_map { |path| [File.basename(path), File.basename(target_path(path))] }
233
+ .uniq
234
+ end
235
+
236
+ # Deduped by expanded path so overlapping roots yield each file once.
237
+ def flat_candidates(spec, kind, manifest:)
238
+ glob = kind.erb ? "{*.md,*.md.erb}" : "*.md"
218
239
  artifact_roots(spec, kind, manifest)
219
- .flat_map { |root| Dir.glob(File.join(root, "*.md")) }
240
+ .flat_map { |root| Dir.glob(File.join(root, glob)) }
220
241
  .uniq { |path| File.expand_path(path) }
221
242
  end
222
243
 
244
+ # A flat kind is identified by its filename, so a static file and a
245
+ # template rendering to that name are the same artifact anywhere in the
246
+ # root set; the static one wins, never silently.
247
+ def resolve_flat_ties(paths, report)
248
+ paths.group_by { |path| File.basename(target_path(path)) }.flat_map do |face, group|
249
+ templates, static = group.partition { |path| erb_template?(path) }
250
+ next group if templates.empty? || static.empty?
251
+
252
+ templates.each { |path| report.skip "skip #{path}: #{face} takes precedence" }
253
+ static
254
+ end
255
+ end
256
+
257
+ # A templated artifact answers to its shipped *.md.erb name and to the
258
+ # rendered face it installs as; the exact spelling wins when a manifest
259
+ # carries both.
260
+ def flat_gate_key(path, kind, manifest, spec, report)
261
+ shipped = File.basename(path)
262
+ face = File.basename(target_path(path))
263
+ return shipped if face == shipped
264
+
265
+ keys = manifest.section_keys(kind.type)
266
+ return face if keys.include?(face) && !keys.include?(shipped)
267
+ if keys.include?(shipped) && keys.include?(face)
268
+ report.warn "#{spec.name}: manifest #{kind.section} keys '#{shipped}' and '#{face}' both gate " \
269
+ "'#{face}'; using '#{shipped}'"
270
+ end
271
+ shipped
272
+ end
273
+
223
274
  # The manifest's directory override is searched in addition to the kind's
224
275
  # convention roots, never instead of them.
225
276
  def artifact_roots(spec, kind, manifest)
@@ -239,7 +290,7 @@ module Rails
239
290
 
240
291
  convention_root = File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive")
241
292
  Dir.glob(File.join(convention_root, "skills", "**", "{SKILL.md,SKILL.md.erb}")).any? ||
242
- Dir.glob(File.join(convention_root, "guidelines", "*.md")).any?
293
+ Dir.glob(File.join(convention_root, "guidelines", "{*.md,*.md.erb}")).any?
243
294
  end
244
295
 
245
296
  def metadata_present?(spec, key)
@@ -257,7 +308,7 @@ module Rails
257
308
 
258
309
  count = found.map { |p| File.dirname(p) }.uniq.size
259
310
  report.notices << "gem '#{spec.name}' ships #{count} skills.sh skill(s); add \"#{spec.name}\" to enabled: " \
260
- "in .hyperdrive/lock.yml and re-run bin/rails hyperdrive:sync to install them"
311
+ "in #{InstallLayout::CONFIG_PATH} and re-run bin/rails hyperdrive:sync to install them"
261
312
  end
262
313
 
263
314
  # Frontmatter's schema is exactly name and description; any other key is
@@ -348,7 +399,7 @@ module Rails
348
399
  # the manifest; the manifest still keys its own entries by the shipped
349
400
  # filename.
350
401
  def prefixed_stem(path, kind, manifest)
351
- stem = File.basename(path, ".md")
402
+ stem = File.basename(target_path(path), ".md")
352
403
  prefix = manifest.name_prefix(kind.type)
353
404
  prefix ? "#{prefix}-#{stem}" : stem
354
405
  end
@@ -629,6 +680,7 @@ module Rails
629
680
  end
630
681
 
631
682
  private_class_method :each_artifact_path, :warn_unknown_manifest_keys,
683
+ :flat_paths, :flat_candidates, :resolve_flat_ties, :flat_gate_key,
632
684
  :artifact_roots, :prefixed_stem, :templates_root,
633
685
  :resolve_same_dir_tie, :pair_with_templates, :warn_template_extras,
634
686
  :opted_in?, :metadata_present?, :notice_skills_sh_content,
@@ -0,0 +1,135 @@
1
+ require "yaml"
2
+ require "rails/hyperdrive/install_layout"
3
+
4
+ module Rails
5
+ module Hyperdrive
6
+ # The hand-owned settings file: which artifacts never to install, and which
7
+ # gems to treat as companions. Fail-open — a malformed part warns and reads
8
+ # as absent, and nothing raises.
9
+ class ConfigFile
10
+ DISABLED_KEYS = { skill: "skills", guideline: "guidelines", agent: "agents", command: "commands" }.freeze
11
+
12
+ attr_reader :path, :warnings, :resolve_command, :resolve_prompt
13
+
14
+ def self.load(path)
15
+ new(path).tap(&:read)
16
+ end
17
+
18
+ def initialize(path)
19
+ @path = path.to_s
20
+ @disabled = empty_disabled
21
+ @enabled = []
22
+ @warnings = []
23
+ end
24
+
25
+ def exist?
26
+ File.file?(@path)
27
+ end
28
+
29
+ def read
30
+ return self unless File.file?(@path)
31
+
32
+ data = YAML.safe_load(File.read(@path))
33
+ return self if data.nil?
34
+ return warn_and_empty("root is not a map; reading it as empty") unless data.is_a?(Hash)
35
+
36
+ read_disabled(data["disabled"])
37
+ read_enabled(data["enabled"])
38
+ read_resolve(data["resolve"])
39
+ self
40
+ rescue StandardError => e
41
+ warn_and_empty("could not be parsed (#{first_line(e.message)}); reading it as empty")
42
+ end
43
+
44
+ def disabled?(type, name)
45
+ Array(@disabled[type.to_sym]).include?(name.to_s)
46
+ end
47
+
48
+ def enabled_gems
49
+ @enabled
50
+ end
51
+
52
+ private
53
+
54
+ def read_disabled(raw)
55
+ return if raw.nil?
56
+ return report("disabled: must be a map of kind to list; ignoring it") unless raw.is_a?(Hash)
57
+
58
+ DISABLED_KEYS.each do |type, key|
59
+ value = raw[key]
60
+ next if value.nil?
61
+ unless value.is_a?(Array)
62
+ report("disabled: #{key}: must be a list of names; ignoring it")
63
+ next
64
+ end
65
+ @disabled[type] = normalize(value)
66
+ end
67
+ end
68
+
69
+ def read_resolve(raw)
70
+ return if raw.nil?
71
+ return report("resolve: must be a map; ignoring it") unless raw.is_a?(Hash)
72
+
73
+ @resolve_command = read_resolve_command(raw["command"])
74
+ @resolve_prompt = read_resolve_prompt(raw["prompt"])
75
+ end
76
+
77
+ def read_resolve_command(value)
78
+ return nil if value.nil?
79
+ return value.strip if value.is_a?(String) && !value.strip.empty?
80
+
81
+ report("resolve: command: must be a non-empty string; ignoring it")
82
+ nil
83
+ end
84
+
85
+ # The prompt is read relative to the app root; a path that escapes it is
86
+ # refused.
87
+ def read_resolve_prompt(value)
88
+ return nil if value.nil?
89
+ unless value.is_a?(String) && !value.strip.empty?
90
+ report("resolve: prompt: must be a non-empty string; ignoring it")
91
+ return nil
92
+ end
93
+
94
+ prompt = value.strip
95
+ if prompt.split("/").include?("..") || prompt.start_with?("/")
96
+ report("resolve: prompt: must be a path inside the app; ignoring it")
97
+ return nil
98
+ end
99
+ prompt
100
+ end
101
+
102
+ def read_enabled(raw)
103
+ return if raw.nil?
104
+ return report("enabled: must be a list of gem names; ignoring it") unless raw.is_a?(Array)
105
+
106
+ @enabled = normalize(raw)
107
+ end
108
+
109
+ def normalize(list)
110
+ list.map { |name| name.to_s.strip }.reject(&:empty?).uniq
111
+ end
112
+
113
+ def warn_and_empty(message)
114
+ report(message)
115
+ @disabled = empty_disabled
116
+ @enabled = []
117
+ @resolve_command = nil
118
+ @resolve_prompt = nil
119
+ self
120
+ end
121
+
122
+ def report(message)
123
+ @warnings << "#{InstallLayout::CONFIG_PATH} #{message}"
124
+ end
125
+
126
+ def first_line(message)
127
+ message.to_s.lines.first.to_s.strip
128
+ end
129
+
130
+ def empty_disabled
131
+ DISABLED_KEYS.keys.each_with_object({}) { |type, h| h[type] = [] }
132
+ end
133
+ end
134
+ end
135
+ end
@@ -11,6 +11,7 @@ module Rails
11
11
  COMMANDS_DIR = ".claude/commands".freeze
12
12
  INDEX_PATH = "#{HYPERDRIVE_DIR}/index.md".freeze
13
13
  LOCK_PATH = ".hyperdrive/lock.yml".freeze
14
+ CONFIG_PATH = ".hyperdrive/config.yml".freeze
14
15
 
15
16
  DEFAULT_TARGET = :claude
16
17
 
@@ -22,7 +23,7 @@ module Rails
22
23
  # identified and validated, and where each target tool installs it.
23
24
  Kind = Struct.new(
24
25
  :type, :shape, :section, :key_label, :shipped_label, :dir_key, :prefix_key,
25
- :convention_roots, :identity, :frontmatter, :install_body,
26
+ :convention_roots, :identity, :frontmatter, :install_body, :erb,
26
27
  :collision_rewrites_name, :eager, :name_from_dest, :dests,
27
28
  keyword_init: true
28
29
  ) do
@@ -63,6 +64,7 @@ module Rails
63
64
  identity: :frontmatter,
64
65
  frontmatter: :required,
65
66
  install_body: :verbatim,
67
+ erb: true,
66
68
  collision_rewrites_name: true,
67
69
  eager: false,
68
70
  name_from_dest: ->(dest) { File.basename(File.dirname(dest)) },
@@ -89,6 +91,7 @@ module Rails
89
91
  identity: :frontmatter,
90
92
  frontmatter: :required,
91
93
  install_body: :strip_frontmatter,
94
+ erb: true,
92
95
  collision_rewrites_name: false,
93
96
  eager: true,
94
97
  name_from_dest: ->(dest) { File.basename(dest, ".md") },
@@ -111,6 +114,7 @@ module Rails
111
114
  identity: :frontmatter,
112
115
  frontmatter: :required,
113
116
  install_body: :verbatim,
117
+ erb: true,
114
118
  collision_rewrites_name: true,
115
119
  eager: false,
116
120
  name_from_dest: ->(dest) { File.basename(dest, ".md") },
@@ -128,6 +132,7 @@ module Rails
128
132
  identity: :filename_stem,
129
133
  frontmatter: :optional,
130
134
  install_body: :verbatim,
135
+ erb: true,
131
136
  collision_rewrites_name: false,
132
137
  eager: false,
133
138
  name_from_dest: ->(dest) { File.basename(dest, ".md") },