rails-hyperdrive 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3fbb6e0845e6194c6b33334e2bd03db6629cd61a8bfd973f8be3ce7b82fd1be
4
- data.tar.gz: 8f259097744b8729ef71756df833809ff315305bf52a822ca1e40fa94298bc26
3
+ metadata.gz: 11be45047a967ade290022f1aa91cd023bf6085e0c84b4f6c08807d9b29f446b
4
+ data.tar.gz: 68205915ef162cc1006685361cb14e1690dcd82b666fbab12a54fdf7d5f26e2a
5
5
  SHA512:
6
- metadata.gz: 3986cc09cecd60a44ecc9899be526c3db981d635a8bc458de956965b227fe51a2d3d4efc8e6da03f8016fc83320d59235d975a2ccdb22d4e86d6d75d7296e970
7
- data.tar.gz: 4524e73cd275471bc2e555ff6845b31b3e4b96f75839bd5629b59611522e85d3dd224c4cc5b197f9a38d1ecf66a6fa9961d9ea60567c9626bb90ff46cc2825de
6
+ metadata.gz: 59852c94a50743b1666ab4c8d973b3a34d5178158e57c7b7e20c98e203c53dca22ac6ef505880f0b5b013b5972f9c2aa66b53ec2b1646eab2b6fef487bd4447a
7
+ data.tar.gz: 37165dc674f138b5f29bd408917f0d4d2e5847079f2f346fcc99d4341d2984d01eea3a228bfb434a0351d8c32115762bfbc5937f296a338e511726be4552c3a4
data/CHANGELOG.md CHANGED
@@ -7,6 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-08-15
11
+
12
+ ### Added
13
+
14
+ - Gem-root manifest: a companion gem now declares artifact gating in a
15
+ `hyperdrive.yml` at its root (or at the path named by a new
16
+ `rails_hyperdrive_manifest` gemspec metadata key; `..` segments or a blank
17
+ value fall back to the conventional path). Top-level `gem:`/`versions:` are
18
+ gem-wide defaults; `skills:` entries (keyed by skill-dir relpath from the
19
+ skills root) and `guidelines:` entries (keyed by filename) override them per
20
+ key, and per-file `conditional:` gating nests inside `skills:` entries. All
21
+ keys optional; malformed gating fails open (warn + install ungated, never
22
+ skip, never raise), and an entry keying no shipped artifact warns — the
23
+ staleness signal for gating detached from content. Shipping a manifest (file
24
+ or metadata key) is also a companion opt-in signal, so a skills.sh-format
25
+ skill repo integrates with zero modification to its skill files.
26
+
27
+ - Template/content pairing for skills, so one companion repo can serve
28
+ `npx skills` / git-clone consumers and rails-hyperdrive at once without
29
+ giving up bundle-conditioned content. A skill directory holding a static
30
+ `SKILL.md` (the universal face: generated definition plus all supporting
31
+ files) pairs with a `SKILL.md.erb` master at the same relative path under a
32
+ templates root — declared via the new `rails_hyperdrive_skill_templates_dir`
33
+ gemspec metadata key, defaulting to the convention path
34
+ `lib/<gem_name>/hyperdrive/skills`. The pair discovers as one skill:
35
+ hyperdrive renders the template against the app's bundle and installs the
36
+ supporting files from the content directory, never reading the static
37
+ `SKILL.md`. Unpaired layouts — every existing companion — behave exactly as
38
+ before.
39
+ - Canonical-render rake tasks for companion repos:
40
+ `require "rails/hyperdrive/skill_tasks"` in the Rakefile provides
41
+ `rake hyperdrive:skills:render` (generate each template's static `SKILL.md`
42
+ with the fail-open canonical binding — every gem present, `gem_version`
43
+ `nil`; the generated face carries only the skills.sh frontmatter, with the
44
+ installer-only `gem:`/`versions:`/`conditional:` keys stripped, as they are
45
+ on install) and `rake hyperdrive:skills:check` (fail listing stale generated
46
+ files — the CI freshness gate). Rails-free; rails-hyperdrive as a
47
+ development dependency suffices.
48
+ - A gem's top-level `skills/` directory is now scanned as a default skills
49
+ root for opted-in companions (convention-path artifacts, a
50
+ `rails_hyperdrive_skills_dir`, `rails_hyperdrive_skill_templates_dir`, or
51
+ `rails_hyperdrive_targets` metadata key, or a lockfile `enabled:` entry are
52
+ the opt-in signals). Roots are deduplicated
53
+ by expanded path, so a companion already declaring
54
+ `rails_hyperdrive_skills_dir: "skills"` sees identical results.
55
+ - A hand-editable `enabled:` list in `.hyperdrive/lock.yml` (gem names,
56
+ mirroring `disabled:`): naming a gem there treats it as an opted-in
57
+ companion, so its top-level `skills/` content installs through the normal
58
+ pipeline. `disabled:` still wins for individual artifacts.
59
+ - `hyperdrive:init` and `hyperdrive:sync` now surface bundled gems that ship
60
+ skills.sh-style `skills/*/SKILL.md` content without opting in as companions
61
+ — report-only, with a pointer to the `enabled:` list; nothing is installed
62
+ until the user opts in.
63
+
64
+ ### Changed
65
+
66
+ - The artifact frontmatter contract is relaxed to the skills.sh base
67
+ contract: only `name` and `description` are required. `gem:` and
68
+ `versions:` are now optional narrowing keys — a missing `gem:` means
69
+ universal (`"*"`), a missing `versions:` means unconstrained. Artifacts
70
+ declaring all four fields behave exactly as before.
71
+ - **Breaking:** gating moved entirely to the gem-root manifest. The
72
+ frontmatter `gem:`/`versions:`/`conditional:` keys are no longer read —
73
+ they are ordinary unknown keys, silently ignored and installed verbatim —
74
+ and installer-key stripping is removed: skill bodies now install, and
75
+ render canonically, byte-identical to their shipped (or ERB-rendered)
76
+ content, frontmatter included. Guideline frontmatter is still stripped on
77
+ install. A companion gating via frontmatter must move those keys into
78
+ `hyperdrive.yml`.
79
+
10
80
  ## [0.4.0] - 2026-08-08
11
81
 
12
82
  ### Added
@@ -353,7 +423,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
353
423
  - `hyperdrive:init` generator that installs architecture skills and auto-discovers
354
424
  per-gem skills.
355
425
 
356
- [Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.4.0...HEAD
426
+ [Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.5.0...HEAD
427
+ [0.5.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.5.0
357
428
  [0.4.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.4.0
358
429
  [0.3.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.3.0
359
430
  [0.2.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.2.0
data/README.md CHANGED
@@ -121,7 +121,7 @@ CLAUDE.md # user-owned; ONE injected line: @.claude
121
121
  index.md # managed aggregator: @guidelines/<name>.md
122
122
  guidelines/<name>.md # companion-shipped, frontmatter stripped
123
123
  .claude/skills/<name>/
124
- SKILL.md # companion-shipped, frontmatter kept minus installer keys
124
+ SKILL.md # companion-shipped, installed verbatim (frontmatter included)
125
125
  <supporting files> # optional companion-shipped extras, installed as shipped (*.md.erb rendered)
126
126
  .hyperdrive/lock.yml # git-tracked manifest (source gem, version, content hash)
127
127
  ```
@@ -152,6 +152,17 @@ The list is yours to edit; the generator only reads it and carries it forward. D
152
152
 
153
153
  To skip installed content wholesale instead, pass `--skip-content` to `hyperdrive:init`.
154
154
 
155
+ ### Opting into a gem's bundled skills
156
+
157
+ Ordinary gems (not built as hyperdrive companions) sometimes ship a top-level `skills/` directory of [skills.sh](https://skills.sh)-style skills. Those are never installed automatically — `hyperdrive:init` and `hyperdrive:sync` only report them, e.g. `gem 'foo' ships 2 skills.sh skill(s)`. To install them, name the gem in the `enabled:` list in `.hyperdrive/lock.yml` and re-run `hyperdrive:sync`:
158
+
159
+ ```yaml
160
+ enabled:
161
+ - foo
162
+ ```
163
+
164
+ An enabled gem is treated as a companion from then on: its skills install through the normal pipeline (including on `bundle install`), and `disabled:` still wins for any individual artifact. The list is hand-edited like `disabled:` and survives every rewrite of the lockfile.
165
+
155
166
  ---
156
167
 
157
168
  ## Safety
@@ -162,29 +173,44 @@ Rails Hyperdrive is **dev-only**, enforced in depth: the engine refuses to handl
162
173
 
163
174
  ## Build a companion gem
164
175
 
165
- Ship markdown at a convention path, declare what it targets, publish. That's the whole contract:
176
+ Ship markdown, declare what it targets, publish. That's the whole contract:
166
177
 
167
178
  ```
168
- lib/<gem_name>/hyperdrive/skills/<name>/SKILL.md # skill (dir-per-skill, may ship supporting files)
179
+ skills/<name>/SKILL.md # skill (dir-per-skill, may ship supporting files)
169
180
  lib/<gem_name>/hyperdrive/guidelines/<name>.md # guideline (flat file)
170
181
  ```
171
182
 
183
+ Top-level `skills/` is the recommended home for skill content — it is the tool-agnostic face of your gem, readable by skills.sh and plain git-clone consumers as well as hyperdrive. `lib/<gem_name>/hyperdrive/` is hyperdrive-specific machinery: guidelines (no skills.sh analogue), ERB skill templates (`SKILL.md.erb` — raw ERB must not reach generic consumers, so keep it out of `skills/`), and legacy static skills (still scanned for back-compat).
184
+
185
+ Frontmatter is pure [skills.sh](https://skills.sh) — only `name` and `description` are read, so a skill repo's content integrates without modification:
186
+
172
187
  ```yaml
173
188
  ---
174
189
  name: jobs-sidekiq # kebab-case; determines the install path
175
190
  description: Background job conventions for Sidekiq.
176
- gem: sidekiq # TARGET gem(s), resolved + version-matched in the bundle
177
- versions: ">= 7.0, < 9.0" # Gem::Requirement matched against the target gem
178
191
  ---
179
192
  ```
180
193
 
181
- And to be suggested by `hyperdrive:discover` before anyone installs you:
194
+ Gating which bundles an artifact installs into — lives in a `hyperdrive.yml` manifest at the gem root (or at the path named by a `rails_hyperdrive_manifest` gemspec metadata key), never in the content. Every key is optional; no manifest (or an empty one) means everything installs universally:
195
+
196
+ ```yaml
197
+ gem: sidekiq # gem-wide default: TARGET gem(s), resolved + version-matched in the bundle
198
+ versions: ">= 7.0, < 9.0" # gem-wide default: Gem::Requirement matched against the target gem
199
+ skills: # per-skill overrides, keyed by skill dir relative to its skills root
200
+ jobs-sidekiq:
201
+ versions: ">= 8.0"
202
+ guidelines: # per-guideline overrides, keyed by filename
203
+ jobs.md:
204
+ gem: sidekiq
205
+ ```
206
+
207
+ Shipping a `hyperdrive.yml` (or declaring `rails_hyperdrive_manifest`) opts your gem in as a companion. Also declare your targets in gemspec metadata — it opts your gem in too, and it is the pre-install targeting signal (how `hyperdrive:discover` suggests you before anyone installs you):
182
208
 
183
209
  ```ruby
184
210
  spec.metadata["rails_hyperdrive_targets"] = "sidekiq"
185
211
  ```
186
212
 
187
- The full contract — multi-target artifacts, multi-file skills, per-file gem gating, ERB-templated content, collision and dedup rules — lives in [docs/COMPANION_GEMS.md](docs/COMPANION_GEMS.md).
213
+ The full contract — multi-target artifacts, multi-file skills, per-file gem gating, ERB-templated content, the template/content paired layout that also serves `npx skills` and git-clone consumers, collision and dedup rules — lives in [docs/COMPANION_GEMS.md](docs/COMPANION_GEMS.md).
188
214
 
189
215
  ---
190
216
 
@@ -1,7 +1,9 @@
1
1
  require "thor"
2
2
  require "rails/hyperdrive"
3
3
  require "rails/hyperdrive/bundler_artifact_discovery"
4
+ require "rails/hyperdrive/install_layout"
4
5
  require "rails/hyperdrive/install_pipeline"
6
+ require "rails/hyperdrive/lock_file"
5
7
  require "generators/hyperdrive/install_summary"
6
8
 
7
9
  module Rails
@@ -29,7 +31,9 @@ module Rails
29
31
  end
30
32
 
31
33
  def discover_artifacts(skip: false)
32
- @artifacts ||= skip ? [] : ::Rails::Hyperdrive::BundlerArtifactDiscovery.discover(warnings: warnings)
34
+ @artifacts ||= skip ? [] : ::Rails::Hyperdrive::BundlerArtifactDiscovery.discover(
35
+ warnings: warnings, enabled_gems: enabled_gems, notices: notices
36
+ )
33
37
  end
34
38
 
35
39
  def install(mode:)
@@ -38,7 +42,8 @@ module Rails
38
42
  shell: @shell,
39
43
  artifacts: discover_artifacts,
40
44
  mode: mode,
41
- warnings: warnings
45
+ warnings: warnings,
46
+ notices: notices
42
47
  )
43
48
  @pipeline.call
44
49
  end
@@ -59,6 +64,16 @@ module Rails
59
64
  @warnings ||= []
60
65
  end
61
66
 
67
+ def notices
68
+ @notices ||= []
69
+ end
70
+
71
+ def enabled_gems
72
+ @enabled_gems ||= ::Rails::Hyperdrive::LockFile.load(
73
+ File.join(root, ::Rails::Hyperdrive::InstallLayout::LOCK_PATH)
74
+ ).enabled_gems
75
+ end
76
+
62
77
  def lock_entries
63
78
  entries = []
64
79
  @pipeline&.lock&.each_entry { |e| entries << e }
@@ -4,6 +4,7 @@ require "rails/hyperdrive/bundler_artifact_discovery"
4
4
  require "rails/hyperdrive/install_layout"
5
5
  require "rails/hyperdrive/install_pipeline"
6
6
  require "rails/hyperdrive/install_shell"
7
+ require "rails/hyperdrive/lock_file"
7
8
 
8
9
  module Rails
9
10
  module Hyperdrive
@@ -46,7 +47,8 @@ module Rails
46
47
  return skip(:not_development) unless development?(env)
47
48
  return skip(:not_initialized) unless File.exist?(File.join(root, InstallLayout::LOCK_PATH))
48
49
 
49
- artifacts = BundlerArtifactDiscovery.discover
50
+ enabled = LockFile.load(File.join(root, InstallLayout::LOCK_PATH)).enabled_gems
51
+ artifacts = BundlerArtifactDiscovery.discover(enabled_gems: enabled)
50
52
  status = ArtifactStatus.compare(root: root, artifacts: artifacts)
51
53
 
52
54
  installed = []
@@ -1,17 +1,17 @@
1
1
  require "yaml"
2
2
  require "bundler"
3
+ require "rails/hyperdrive/gem_manifest"
3
4
  require "rails/hyperdrive/skill_template"
4
5
 
5
6
  module Rails
6
7
  module Hyperdrive
7
8
  module BundlerArtifactDiscovery
8
9
  SKILL_FILE_NAMES = ["SKILL.md", "SKILL.md.erb"].freeze
9
- INSTALLER_KEY = /\A(?:gem|versions|conditional):/.freeze
10
10
 
11
11
  Artifact = Struct.new(
12
12
  :name, :description, :target_gem, :versions, :artifact_type,
13
13
  :source_gem, :path, :body, :spec_version, :support_files,
14
- :source_root,
14
+ :source_root, :support_root,
15
15
  keyword_init: true
16
16
  ) do
17
17
  def skill?
@@ -34,17 +34,30 @@ module Rails
34
34
  module_function
35
35
 
36
36
  # Non-fatal problems are appended to `warnings` and the artifact is
37
- # dropped; discovery never raises.
38
- def discover(specs: nil, warnings: [])
37
+ # dropped; discovery never raises. A gem that has not opted in as a
38
+ # companion is never scanned — its skills.sh content is only reported
39
+ # through `notices`.
40
+ def discover(specs: nil, warnings: [], enabled_gems: [], notices: [])
39
41
  specs ||= safe_bundler_specs
42
+ enabled = Array(enabled_gems).map(&:to_s)
40
43
  resolved = specs.each_with_object({}) { |s, h| h[s.name.to_s] = s.version }
41
44
 
42
45
  candidates = []
43
46
  specs.each do |spec|
44
- each_artifact_path(spec, warnings: warnings) do |path, type|
45
- artifact = parse(path, source_spec: spec, type: type, resolved: resolved, warnings: warnings)
47
+ unless opted_in?(spec, enabled_gems: enabled)
48
+ notice_skills_sh_content(spec, notices: notices)
49
+ next
50
+ end
51
+ manifest = GemManifest.load(spec, warnings: warnings)
52
+ seen = { skill: [], guideline: [] }
53
+ each_artifact_path(spec, warnings: warnings) do |path, type, support_root, key|
54
+ seen[type] << key
55
+ gate = type == :skill ? manifest.skill_gate(key) : manifest.guideline_gate(key)
56
+ artifact = parse(path, source_spec: spec, type: type, resolved: resolved,
57
+ warnings: warnings, support_root: support_root, gate: gate)
46
58
  candidates << artifact if artifact
47
59
  end
60
+ warn_unknown_manifest_keys(manifest, spec, seen, warnings)
48
61
  end
49
62
 
50
63
  # Collapse same-name variants within one source gem (highest
@@ -55,29 +68,137 @@ module Rails
55
68
  end
56
69
  end
57
70
 
71
+ # Yields each candidate with its manifest join key: a skill's relpath
72
+ # from its skills root, a guideline's filename. Keys are collected before
73
+ # parsing, so a candidate later dropped (e.g. an ERB render failure)
74
+ # still counts as known to the manifest.
58
75
  def each_artifact_path(spec, warnings: [])
59
- skill_paths(spec, warnings: warnings).each { |p| yield p, :skill }
60
- guideline_paths(spec).each { |p| yield p, :guideline }
76
+ skill_paths(spec, warnings: warnings).each { |path, support_root, rel| yield path, :skill, support_root, rel }
77
+ guideline_paths(spec).each { |p| yield p, :guideline, nil, File.basename(p) }
78
+ end
79
+
80
+ # The staleness signal for gating detached from content: an entry
81
+ # matching nothing means a renamed or removed skill dir/guideline.
82
+ def warn_unknown_manifest_keys(manifest, spec, seen, warnings)
83
+ (manifest.skill_keys - seen[:skill]).each do |key|
84
+ warnings << "#{spec.name}: manifest skills entry '#{key}' names no shipped skill directory"
85
+ end
86
+ (manifest.guideline_keys - seen[:guideline]).each do |key|
87
+ warnings << "#{spec.name}: manifest guidelines entry '#{key}' names no shipped guideline"
88
+ end
61
89
  end
62
90
 
63
91
  def skill_paths(spec, warnings: [])
64
- roots = [File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "skills")]
92
+ roots = [
93
+ File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "skills"),
94
+ File.join(spec.full_gem_path, "skills")
95
+ ]
65
96
  if (override = skills_dir_override(spec))
66
97
  roots << File.join(spec.full_gem_path, override)
67
98
  end
68
- found = roots.flat_map { |root| Dir.glob(File.join(root, "**", "{SKILL.md,SKILL.md.erb}")) }.uniq
69
- found.group_by { |p| File.dirname(p) }.flat_map do |dir, group|
70
- next group unless group.size > 1
71
- warnings << "skip #{File.join(dir, "SKILL.md.erb")}: SKILL.md in the same directory takes precedence"
72
- group.select { |p| File.basename(p) == "SKILL.md" }
99
+
100
+ candidates = []
101
+ seen = {}
102
+ roots.each do |root|
103
+ Dir.glob(File.join(root, "**", "{SKILL.md,SKILL.md.erb}"))
104
+ .group_by { |p| File.dirname(p) }.each do |dir, group|
105
+ next if seen[File.expand_path(dir)]
106
+ seen[File.expand_path(dir)] = true
107
+ candidates << {
108
+ dir: dir,
109
+ rel: dir.delete_prefix(root).delete_prefix("/"),
110
+ path: resolve_same_dir_tie(dir, group, warnings)
111
+ }
112
+ end
113
+ end
114
+
115
+ pair_with_templates(candidates, spec, warnings: warnings)
116
+ end
117
+
118
+ def resolve_same_dir_tie(dir, group, warnings)
119
+ return group.first unless group.size > 1
120
+ warnings << "skip #{File.join(dir, "SKILL.md.erb")}: SKILL.md in the same directory takes precedence"
121
+ group.find { |p| File.basename(p) == "SKILL.md" }
122
+ end
123
+
124
+ # A dir holding a static SKILL.md pairs with <templates root>/<same
125
+ # relative path>/SKILL.md.erb in a distinct dir: the rendered template is
126
+ # the definition, the static dir the support root. The static SKILL.md is
127
+ # never parsed — falling back to it when the template fails to render
128
+ # would silently un-condition the skill.
129
+ def pair_with_templates(candidates, spec, warnings:)
130
+ templates_root = File.join(spec.full_gem_path, skill_templates_dir(spec))
131
+
132
+ pairs = {}
133
+ candidates.each do |cand|
134
+ next unless File.basename(cand[:path]) == "SKILL.md"
135
+ template_dir = File.expand_path(File.join(templates_root, cand[:rel]))
136
+ next if template_dir == File.expand_path(cand[:dir])
137
+ template = File.join(template_dir, "SKILL.md.erb")
138
+ next unless File.file?(template)
139
+ warn_template_extras(template_dir, warnings)
140
+ pairs[cand[:dir]] = { template: template, template_dir: template_dir }
141
+ end
142
+
143
+ consumed = pairs.values.map { |p| p[:template_dir] }
144
+ candidates.filter_map do |cand|
145
+ if (pair = pairs[cand[:dir]])
146
+ [pair[:template], cand[:dir], cand[:rel]]
147
+ elsif consumed.include?(File.expand_path(cand[:dir]))
148
+ nil
149
+ else
150
+ [cand[:path], File.dirname(cand[:path]), cand[:rel]]
151
+ end
73
152
  end
74
153
  end
75
154
 
155
+ # The content dir is the single source of truth for supporting files.
156
+ def warn_template_extras(template_dir, warnings)
157
+ extras = Dir.glob(File.join(template_dir, "**", "*"))
158
+ .select { |f| File.file?(f) && f != File.join(template_dir, "SKILL.md.erb") }
159
+ return if extras.empty?
160
+ warnings << "#{template_dir}: ignoring #{extras.size} file(s) besides SKILL.md.erb; " \
161
+ "supporting files ship in the paired content directory"
162
+ end
163
+
76
164
  def guideline_paths(spec)
77
165
  root = File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive", "guidelines")
78
166
  Dir.glob(File.join(root, "*.md"))
79
167
  end
80
168
 
169
+ # Many gemspecs package files via `git ls-files`, so a contributor-facing
170
+ # skills/ dir ships by accident — package contents don't signal consumer
171
+ # intent. Only an explicit signal makes a gem's content installable.
172
+ def opted_in?(spec, enabled_gems:)
173
+ return true if enabled_gems.include?(spec.name.to_s)
174
+ return true if metadata_present?(spec, "rails_hyperdrive_skills_dir")
175
+ return true if metadata_present?(spec, "rails_hyperdrive_skill_templates_dir")
176
+ return true if metadata_present?(spec, "rails_hyperdrive_targets")
177
+ return true if GemManifest.opt_in?(spec)
178
+
179
+ convention_root = File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive")
180
+ Dir.glob(File.join(convention_root, "skills", "**", "{SKILL.md,SKILL.md.erb}")).any? ||
181
+ Dir.glob(File.join(convention_root, "guidelines", "*.md")).any?
182
+ end
183
+
184
+ def metadata_present?(spec, key)
185
+ return false unless spec.respond_to?(:metadata)
186
+ raw = spec.metadata && spec.metadata[key]
187
+ !raw.to_s.strip.empty?
188
+ end
189
+
190
+ # Report-only, glob-only (no file reads): SKILL.md presence is the
191
+ # signal, and SKILL.md.erb is excluded — raw ERB is not skills.sh
192
+ # content. Parse problems surface as warnings once the gem is enabled.
193
+ def notice_skills_sh_content(spec, notices:)
194
+ found = Dir.glob(File.join(spec.full_gem_path, "skills", "**", "SKILL.md"))
195
+ return if found.empty?
196
+
197
+ count = found.map { |p| File.dirname(p) }.uniq.size
198
+ notices << "gem '#{spec.name}' ships #{count} skills.sh skill(s); add \"#{spec.name}\" to enabled: " \
199
+ "in .hyperdrive/lock.yml and re-run bin/rails hyperdrive:sync to install them"
200
+ end
201
+
81
202
  # ".." segments are rejected to prevent escaping the gem root.
82
203
  def skills_dir_override(spec)
83
204
  return nil unless spec.respond_to?(:metadata)
@@ -87,7 +208,19 @@ module Rails
87
208
  raw.to_s
88
209
  end
89
210
 
90
- def parse(path, source_spec:, type:, resolved:, warnings:)
211
+ def skill_templates_dir(spec)
212
+ default = File.join("lib", spec.name, "hyperdrive", "skills")
213
+ return default unless spec.respond_to?(:metadata)
214
+ raw = spec.metadata && spec.metadata["rails_hyperdrive_skill_templates_dir"]
215
+ return default if raw.nil? || raw.to_s.strip.empty?
216
+ return default if raw.to_s.split(%r{[/\\]}).include?("..")
217
+ raw.to_s
218
+ end
219
+
220
+ # Frontmatter's schema is exactly name and description; any other key is
221
+ # unknown to the parser and rides untouched in the installed body.
222
+ def parse(path, source_spec:, type:, resolved:, warnings:, gate:, support_root: nil)
223
+ support_root ||= File.dirname(path)
91
224
  body = File.read(path)
92
225
  if erb_template?(path)
93
226
  begin
@@ -103,25 +236,20 @@ module Rails
103
236
  return nil
104
237
  end
105
238
 
106
- meta = YAML.safe_load(frontmatter, permitted_classes: [Symbol]) || {}
239
+ # Date is permitted because unknown frontmatter keys are user content;
240
+ # a bare date value must not fail the parse.
241
+ meta = YAML.safe_load(frontmatter, permitted_classes: [Symbol, Date]) || {}
107
242
  name = meta["name"]
108
243
  description = meta["description"]
109
- versions = meta["versions"]
110
-
111
- unless name && description && meta["gem"] && versions
112
- warnings << "skip #{path}: missing a required field (name, description, gem, versions)"
113
- return nil
114
- end
115
244
 
116
- targets = parse_targets(meta["gem"])
117
- if targets.nil? || targets.empty?
118
- warnings << "skip #{name} (from #{source_spec.name}): gem: must name a gem, a comma-separated list, or a YAML list"
245
+ unless name && description
246
+ warnings << "skip #{path}: missing a required field (name, description)"
119
247
  return nil
120
248
  end
121
249
 
122
- matched = match_targets(targets, versions, resolved)
250
+ matched = match_targets(gate.targets, gate.versions, resolved)
123
251
  if matched.empty?
124
- warnings << "skip #{name} (from #{source_spec.name}): #{no_match_reason(targets, versions, resolved)}"
252
+ warnings << "skip #{name} (from #{source_spec.name}): #{no_match_reason(gate.targets, gate.versions, resolved)}"
125
253
  return nil
126
254
  end
127
255
 
@@ -129,17 +257,18 @@ module Rails
129
257
  name: name.to_s,
130
258
  description: description.to_s,
131
259
  target_gem: matched,
132
- versions: versions,
260
+ versions: gate.versions,
133
261
  artifact_type: type,
134
262
  source_gem: source_spec.name.to_s,
135
263
  path: path,
136
264
  body: body,
137
265
  spec_version: source_spec.version.to_s,
138
266
  source_root: source_spec.full_gem_path.to_s,
267
+ support_root: support_root,
139
268
  support_files:
140
269
  if type == :skill
141
270
  conditioned_support_files(
142
- File.dirname(path), meta["conditional"],
271
+ support_root, gate.conditional,
143
272
  resolved: resolved, warnings: warnings,
144
273
  label: "#{name} (from #{source_spec.name})"
145
274
  )
@@ -147,7 +276,7 @@ module Rails
147
276
  []
148
277
  end
149
278
  )
150
- rescue Psych::SyntaxError
279
+ rescue Psych::Exception
151
280
  warnings << "skip #{path}: malformed YAML frontmatter"
152
281
  nil
153
282
  end
@@ -185,7 +314,7 @@ module Rails
185
314
  shipped = files.map { |f| f[:path] }
186
315
  conditions.each_key do |key|
187
316
  if SKILL_FILE_NAMES.include?(key)
188
- warnings << "#{label}: conditional key '#{key}' ignored; the skill's own gem:/versions: gate the whole skill"
317
+ warnings << "#{label}: conditional key '#{key}' ignored; the entry's own gem:/versions: gate the whole skill"
189
318
  elsif !shipped.include?(key)
190
319
  warnings << "#{label}: conditional key '#{key}' names no shipped supporting file"
191
320
  end
@@ -203,14 +332,14 @@ module Rails
203
332
  return true
204
333
  end
205
334
 
206
- targets = entry["gem"] && parse_targets(entry["gem"])
335
+ targets = entry["gem"] && GemManifest.parse_targets(entry["gem"])
207
336
  if targets.nil? || targets.empty?
208
337
  warnings << "#{label}: conditional entry for '#{key}' needs gem: naming a gem, a comma-separated list, or a YAML list; installing the file"
209
338
  return true
210
339
  end
211
340
 
212
341
  versions = entry["versions"]
213
- if malformed_requirements?(versions)
342
+ if GemManifest.malformed_requirements?(versions)
214
343
  warnings << "#{label}: conditional entry for '#{key}' has an unparsable versions: requirement; installing the file"
215
344
  return true
216
345
  end
@@ -218,20 +347,6 @@ module Rails
218
347
  match_targets(targets, versions, resolved).any?
219
348
  end
220
349
 
221
- # versions: is optional in a conditional entry; nil means unconstrained.
222
- def malformed_requirements?(versions)
223
- requirements = versions.is_a?(Hash) ? versions.values : [versions]
224
- requirements.compact.any? do |req|
225
- parts = Array(req).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
226
- begin
227
- Gem::Requirement.new(*parts)
228
- false
229
- rescue ArgumentError
230
- true
231
- end
232
- end
233
- end
234
-
235
350
  def render_support_templates(files, skill_dir, resolved:, warnings:)
236
351
  plain_paths = files.map { |f| f[:path] }
237
352
  files.filter_map do |file|
@@ -256,36 +371,15 @@ module Rails
256
371
  path.end_with?(".md.erb")
257
372
  end
258
373
 
374
+ # Guideline frontmatter is stripped because the installed file is
375
+ # @-included eagerly into agent context, where it would be inert noise.
259
376
  def install_ready_body(artifact)
260
- return strip_installer_keys(artifact.body) if artifact.skill?
377
+ return artifact.body if artifact.skill?
261
378
 
262
379
  _frontmatter, rest = split_frontmatter(artifact.body)
263
380
  (rest || artifact.body).sub(/\A\n+/, "")
264
381
  end
265
382
 
266
- # gem:/versions:/conditional: are install-time inputs with no reader
267
- # after install, and conditional: keys name *shipped* paths that gating
268
- # and ERB retargeting can leave pointing at files absent from disk — so
269
- # the installed frontmatter carries neither.
270
- def strip_installer_keys(body)
271
- frontmatter, rest = split_frontmatter(body)
272
- return body unless frontmatter
273
-
274
- kept = []
275
- skipping = false
276
- frontmatter.lines.each do |line|
277
- if line =~ INSTALLER_KEY
278
- skipping = true
279
- elsif skipping && (line.start_with?(" ", "\t") || line.strip.empty?)
280
- # continuation of a stripped key's block
281
- else
282
- skipping = false
283
- kept << line
284
- end
285
- end
286
- "---\n#{kept.join}---\n#{rest}"
287
- end
288
-
289
383
  def split_frontmatter(body)
290
384
  lines = body.lines
291
385
  return [nil, body] unless lines.first&.strip == "---"
@@ -297,12 +391,6 @@ module Rails
297
391
  [lines[1...absolute_closing].join, lines[(absolute_closing + 1)..].join]
298
392
  end
299
393
 
300
- def parse_targets(raw)
301
- entries = raw.is_a?(Array) ? raw : [raw]
302
- return nil if entries.any? { |e| e.nil? || e.is_a?(Array) || e.is_a?(Hash) }
303
- entries.flat_map { |e| e.to_s.split(",") }.map(&:strip).reject(&:empty?)
304
- end
305
-
306
394
  def match_targets(targets, versions, resolved)
307
395
  return ["*"] if targets.include?("*")
308
396
 
@@ -334,13 +422,15 @@ module Rails
334
422
  []
335
423
  end
336
424
 
337
- private_class_method :each_artifact_path, :skill_paths, :guideline_paths,
338
- :skills_dir_override, :parse, :support_files_for,
425
+ private_class_method :each_artifact_path, :warn_unknown_manifest_keys, :skill_paths,
426
+ :guideline_paths,
427
+ :resolve_same_dir_tie, :pair_with_templates, :warn_template_extras,
428
+ :opted_in?, :metadata_present?, :notice_skills_sh_content,
429
+ :skills_dir_override, :skill_templates_dir, :parse, :support_files_for,
339
430
  :conditioned_support_files, :apply_conditional_filter,
340
- :conditional_satisfied?, :malformed_requirements?,
431
+ :conditional_satisfied?,
341
432
  :render_support_templates, :erb_template?,
342
- :strip_installer_keys,
343
- :split_frontmatter, :parse_targets, :match_targets,
433
+ :match_targets,
344
434
  :version_satisfied?, :no_match_reason, :safe_bundler_specs
345
435
  end
346
436
  end
@@ -0,0 +1,105 @@
1
+ require "fileutils"
2
+ require "yaml"
3
+ require "rails/hyperdrive/bundler_artifact_discovery"
4
+ require "rails/hyperdrive/skill_template"
5
+
6
+ module Rails
7
+ module Hyperdrive
8
+ # Renders each SKILL.md.erb template in a companion gem repo to the static
9
+ # SKILL.md it pairs with, using the canonical fail-open binding.
10
+ # Companion-repo dev tooling: problems raise.
11
+ module CanonicalSkillRender
12
+ Error = Class.new(StandardError)
13
+
14
+ Rendered = Struct.new(:template, :dest, :body, keyword_init: true)
15
+
16
+ module_function
17
+
18
+ def write(gemspec: nil, dir: Dir.pwd)
19
+ render_all(gemspec: gemspec, dir: dir).each do |r|
20
+ FileUtils.mkdir_p(File.dirname(r.dest))
21
+ File.write(r.dest, r.body)
22
+ end
23
+ end
24
+
25
+ def stale(gemspec: nil, dir: Dir.pwd)
26
+ render_all(gemspec: gemspec, dir: dir).reject do |r|
27
+ File.file?(r.dest) && File.read(r.dest) == r.body
28
+ end
29
+ end
30
+
31
+ def render_all(gemspec: nil, dir: Dir.pwd)
32
+ spec_path = resolve_gemspec_path(gemspec, dir)
33
+ spec = Gem::Specification.load(spec_path)
34
+ raise Error, "could not load gemspec #{spec_path}" unless spec
35
+
36
+ gem_root = File.dirname(File.expand_path(spec_path))
37
+ skills_root = File.join(gem_root, metadata_dir(spec, "rails_hyperdrive_skills_dir"))
38
+ templates_root = File.join(gem_root, metadata_dir(spec, "rails_hyperdrive_skill_templates_dir"))
39
+
40
+ Dir.glob(File.join(templates_root, "**", "SKILL.md.erb")).sort.map do |template|
41
+ rel = File.dirname(template).delete_prefix(templates_root).delete_prefix("/")
42
+ dest_dir = File.join(skills_root, rel)
43
+ # A SKILL.md written beside the SKILL.md.erb would take precedence
44
+ # over it at discovery time, silently demoting the skill to its
45
+ # static face.
46
+ if File.expand_path(dest_dir) == File.expand_path(File.dirname(template))
47
+ raise Error, "#{template}: content dir equals template dir; set " \
48
+ "spec.metadata[\"rails_hyperdrive_skills_dir\"] to a separate root"
49
+ end
50
+
51
+ body = render_template(template)
52
+ validate_output!(body, template)
53
+ Rendered.new(template: template, dest: File.join(dest_dir, "SKILL.md"), body: body)
54
+ end
55
+ end
56
+
57
+ def resolve_gemspec_path(explicit, dir)
58
+ if explicit && !explicit.to_s.strip.empty?
59
+ path = File.expand_path(explicit.to_s, dir)
60
+ raise Error, "gemspec not found: #{path}" unless File.file?(path)
61
+ return path
62
+ end
63
+
64
+ found = Dir.glob(File.join(dir, "*.gemspec"))
65
+ case found.size
66
+ when 1 then File.expand_path(found.first)
67
+ when 0 then raise Error, "no .gemspec found in #{dir}; pass an explicit path, " \
68
+ "e.g. rake \"hyperdrive:skills:render[path/to/name.gemspec]\""
69
+ else raise Error, "multiple gemspecs found in #{dir} " \
70
+ "(#{found.map { |f| File.basename(f) }.join(", ")}); pass an explicit path"
71
+ end
72
+ end
73
+
74
+ def metadata_dir(spec, key)
75
+ default = File.join("lib", spec.name, "hyperdrive", "skills")
76
+ raw = spec.metadata && spec.metadata[key]
77
+ return default if raw.nil? || raw.to_s.strip.empty?
78
+ raise Error, "gemspec metadata #{key} must not contain '..' segments" if
79
+ raw.to_s.split(%r{[/\\]}).include?("..")
80
+ raw.to_s
81
+ end
82
+
83
+ def render_template(template)
84
+ SkillTemplate.render_canonical(File.read(template))
85
+ rescue SyntaxError, StandardError => e
86
+ raise Error, "#{template}: canonical render failed (#{e.message})"
87
+ end
88
+
89
+ # A static face with unusable frontmatter would ship a broken skill to
90
+ # consumers that read it directly.
91
+ def validate_output!(body, template)
92
+ frontmatter, = BundlerArtifactDiscovery.split_frontmatter(body)
93
+ raise Error, "#{template}: rendered output has no YAML frontmatter" unless frontmatter
94
+
95
+ meta = YAML.safe_load(frontmatter, permitted_classes: [Symbol]) || {}
96
+ return if meta["name"] && meta["description"]
97
+ raise Error, "#{template}: rendered frontmatter lacks name: or description:"
98
+ rescue Psych::SyntaxError
99
+ raise Error, "#{template}: rendered frontmatter is not parseable YAML"
100
+ end
101
+
102
+ private_class_method :resolve_gemspec_path, :metadata_dir, :render_template, :validate_output!
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,175 @@
1
+ require "yaml"
2
+
3
+ module Rails
4
+ module Hyperdrive
5
+ # A companion gem's root manifest (hyperdrive.yml) declares artifact
6
+ # gating: top-level gem:/versions: defaults for the whole gem,
7
+ # per-skill entries keyed by the skill dir's relative path from its skills
8
+ # root, per-guideline entries keyed by filename. Fail-open at every level:
9
+ # malformed input warns and resolves to an ungated install — an artifact is
10
+ # never skipped because its gating could not be read, and nothing raises.
11
+ class GemManifest
12
+ FILE_NAME = "hyperdrive.yml"
13
+ METADATA_KEY = "rails_hyperdrive_manifest"
14
+
15
+ UNGATED = ["*"].freeze
16
+
17
+ Gate = Struct.new(:targets, :versions, :conditional, keyword_init: true)
18
+
19
+ class << self
20
+ def load(spec, warnings: [])
21
+ new(spec, warnings: warnings)
22
+ end
23
+
24
+ # The metadata key counts even when its value is unusable — declaring
25
+ # it at all signals companion intent.
26
+ def opt_in?(spec)
27
+ return true if File.file?(File.join(spec.full_gem_path, FILE_NAME))
28
+ return false unless spec.respond_to?(:metadata)
29
+
30
+ raw = spec.metadata && spec.metadata[METADATA_KEY]
31
+ !raw.to_s.strip.empty?
32
+ end
33
+
34
+ def parse_targets(raw)
35
+ entries = raw.is_a?(Array) ? raw : [raw]
36
+ return nil if entries.any? { |e| e.nil? || e.is_a?(Array) || e.is_a?(Hash) }
37
+ entries.flat_map { |e| e.to_s.split(",") }.map(&:strip).reject(&:empty?)
38
+ end
39
+
40
+ # versions: is optional everywhere; nil means unconstrained.
41
+ def malformed_requirements?(versions)
42
+ requirements = versions.is_a?(Hash) ? versions.values : [versions]
43
+ requirements.compact.any? do |req|
44
+ parts = Array(req).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
45
+ begin
46
+ Gem::Requirement.new(*parts)
47
+ false
48
+ rescue ArgumentError
49
+ true
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ def initialize(spec, warnings:)
56
+ @spec = spec
57
+ @warnings = warnings
58
+ root = load_root
59
+ @skills = section(root, "skills", "skill relpath")
60
+ @guidelines = section(root, "guidelines", "guideline filename")
61
+ @default_targets, @default_versions = defaults(root)
62
+ end
63
+
64
+ def skill_gate(rel)
65
+ return default_gate unless @skills.key?(rel)
66
+ entry_gate(@skills[rel], rel, with_conditional: true)
67
+ end
68
+
69
+ def guideline_gate(filename)
70
+ return default_gate unless @guidelines.key?(filename)
71
+ entry_gate(@guidelines[filename], filename, with_conditional: false)
72
+ end
73
+
74
+ def skill_keys
75
+ @skills.keys
76
+ end
77
+
78
+ def guideline_keys
79
+ @guidelines.keys
80
+ end
81
+
82
+ private
83
+
84
+ def report(message)
85
+ @warnings << "#{@spec.name}: #{message}"
86
+ end
87
+
88
+ # ".." segments are rejected (silent fallback to the conventional path)
89
+ # to prevent escaping the gem root.
90
+ def manifest_relpath
91
+ return FILE_NAME unless @spec.respond_to?(:metadata)
92
+ raw = @spec.metadata && @spec.metadata[METADATA_KEY]
93
+ return FILE_NAME if raw.nil? || raw.to_s.strip.empty?
94
+ return FILE_NAME if raw.to_s.split(%r{[/\\]}).include?("..")
95
+ raw.to_s
96
+ end
97
+
98
+ # An empty file is a valid manifest (nothing gated); only content that
99
+ # cannot be read as a YAML map warns.
100
+ def load_root
101
+ path = File.join(@spec.full_gem_path, manifest_relpath)
102
+ return {} unless File.file?(path)
103
+
104
+ data = YAML.safe_load(File.read(path), permitted_classes: [Symbol])
105
+ return {} if data.nil?
106
+ unless data.is_a?(Hash)
107
+ report("ignoring manifest #{manifest_relpath}: root must be a YAML map")
108
+ return {}
109
+ end
110
+ data.transform_keys(&:to_s)
111
+ rescue Psych::SyntaxError
112
+ report("ignoring manifest #{manifest_relpath}: malformed YAML")
113
+ {}
114
+ rescue StandardError => e
115
+ report("ignoring manifest #{manifest_relpath}: unreadable (#{e.message})")
116
+ {}
117
+ end
118
+
119
+ def section(root, key, key_label)
120
+ value = root[key]
121
+ return {} if value.nil?
122
+ unless value.is_a?(Hash)
123
+ report("manifest #{key}: must be a map of #{key_label} to {gem:, versions:}; ignoring the section")
124
+ return {}
125
+ end
126
+ value.transform_keys(&:to_s)
127
+ end
128
+
129
+ def defaults(root)
130
+ targets = root.key?("gem") ? self.class.parse_targets(root["gem"]) : nil
131
+ bad_targets = root.key?("gem") && (targets.nil? || targets.empty?)
132
+ if bad_targets || self.class.malformed_requirements?(root["versions"])
133
+ report("manifest top-level gem:/versions: defaults are unusable; ignoring them")
134
+ return [nil, nil]
135
+ end
136
+ [targets, root["versions"]]
137
+ end
138
+
139
+ def default_gate
140
+ Gate.new(targets: @default_targets || UNGATED, versions: @default_versions, conditional: nil)
141
+ end
142
+
143
+ def ungated
144
+ Gate.new(targets: UNGATED, versions: nil, conditional: nil)
145
+ end
146
+
147
+ # A malformed entry drops the gem-wide defaults too: gating that cannot
148
+ # be read must not skip the artifact, so it installs ungated.
149
+ def entry_gate(entry, key, with_conditional:)
150
+ unless entry.is_a?(Hash)
151
+ report("manifest entry for '#{key}' must be a map with gem:/versions:; installing ungated")
152
+ return ungated
153
+ end
154
+
155
+ entry = entry.transform_keys(&:to_s)
156
+ targets = entry.key?("gem") ? self.class.parse_targets(entry["gem"]) : nil
157
+ if entry.key?("gem") && (targets.nil? || targets.empty?)
158
+ report("manifest entry for '#{key}': gem: must name a gem, a comma-separated list, " \
159
+ "or a YAML list; installing ungated")
160
+ return ungated
161
+ end
162
+ if self.class.malformed_requirements?(entry["versions"])
163
+ report("manifest entry for '#{key}' has an unparsable versions: requirement; installing ungated")
164
+ return ungated
165
+ end
166
+
167
+ Gate.new(
168
+ targets: targets || @default_targets || UNGATED,
169
+ versions: entry.key?("versions") ? entry["versions"] : @default_versions,
170
+ conditional: with_conditional ? entry["conditional"] : nil
171
+ )
172
+ end
173
+ end
174
+ end
175
+ end
@@ -26,7 +26,7 @@ module Rails
26
26
  Result = Struct.new(:installed, :updated, :unchanged, :skipped, :orphaned, :removed, :merged, :sidecars,
27
27
  keyword_init: true)
28
28
 
29
- def initialize(root:, shell:, artifacts:, mode: :preserve, warnings: [])
29
+ def initialize(root:, shell:, artifacts:, mode: :preserve, warnings: [], notices: [])
30
30
  raise ArgumentError, "unknown mode #{mode.inspect}" unless MODES.include?(mode)
31
31
 
32
32
  @root = File.expand_path(root.to_s)
@@ -34,6 +34,7 @@ module Rails
34
34
  @artifacts = artifacts
35
35
  @mode = mode
36
36
  @warnings = warnings
37
+ @notices = notices
37
38
  @result = Result.new(
38
39
  installed: [], updated: [], unchanged: [], skipped: [], orphaned: [], removed: [], merged: [], sidecars: []
39
40
  )
@@ -54,6 +55,7 @@ module Rails
54
55
  write_claude_md(guidelines)
55
56
  write_lock
56
57
  print_warnings
58
+ print_notices
57
59
  print_footprint(guidelines: eager_guidelines)
58
60
  warn_if_destinations_gitignored
59
61
  @result
@@ -115,6 +117,7 @@ module Rails
115
117
  def install_skills
116
118
  @plan.select { |e| e.type == :skill }.each do |entry|
117
119
  skill_relpath = source_relpath_for(entry.artifact)
120
+ support_base = support_relpath_base(entry.artifact, skill_relpath)
118
121
  install_file(entry: entry, type: :skill, install_ready_body: entry.install_ready_body,
119
122
  source_relpath: skill_relpath, final_name: entry.final_name)
120
123
  entry.support_files.each do |file|
@@ -125,7 +128,7 @@ module Rails
125
128
  source_gem: entry.source_gem,
126
129
  version: entry.version,
127
130
  artifact_kind: "skill_support",
128
- source_relpath: skill_relpath && File.join(File.dirname(skill_relpath), file[:path])
131
+ source_relpath: support_base && File.join(support_base, file[:path])
129
132
  )
130
133
  end
131
134
  end
@@ -155,6 +158,22 @@ module Rails
155
158
  rel.end_with?(".md.erb") ? rel.delete_suffix(".erb") : rel
156
159
  end
157
160
 
161
+ # Supporting files live under the artifact's support root, which for a
162
+ # template-paired skill is not the definition file's directory.
163
+ def support_relpath_base(artifact, skill_relpath)
164
+ support_root = artifact.support_root
165
+ return skill_relpath && File.dirname(skill_relpath) if support_root.nil? || support_root.to_s.empty?
166
+
167
+ root = artifact.source_root
168
+ return nil if root.nil? || root.to_s.empty?
169
+
170
+ prefix = "#{File.expand_path(root.to_s)}/"
171
+ expanded = File.expand_path(support_root.to_s)
172
+ return nil unless expanded.start_with?(prefix)
173
+
174
+ expanded.delete_prefix(prefix)
175
+ end
176
+
158
177
  def install_file(dest: nil, type:, install_ready_body:, entry: nil, source_gem: nil, version: nil,
159
178
  artifact_kind: nil, source_relpath: nil, final_name: nil)
160
179
  write = {
@@ -524,6 +543,12 @@ module Rails
524
543
  @warnings.each { |w| @shell.say " - #{w}" }
525
544
  end
526
545
 
546
+ def print_notices
547
+ return if additive? || Array(@notices).empty?
548
+ @shell.say ""
549
+ @notices.each { |n| @shell.say_status :info, n, :blue }
550
+ end
551
+
527
552
  def print_footprint(guidelines:)
528
553
  return if additive?
529
554
 
@@ -33,6 +33,7 @@ module Rails
33
33
  @files = {} # path(String) => Entry
34
34
  @document = {} # raw parsed YAML, kept so unknown keys survive
35
35
  @disabled = empty_disabled
36
+ @enabled = []
36
37
  end
37
38
 
38
39
  def read
@@ -45,6 +46,7 @@ module Rails
45
46
  claude_md = data["claude_md"]
46
47
  @claude_md_state = claude_md["state"] if claude_md.is_a?(Hash)
47
48
  @disabled = parse_disabled(data["disabled"])
49
+ @enabled = parse_enabled(data["enabled"])
48
50
  Array(data["files"]).each do |raw|
49
51
  next unless raw.is_a?(Hash)
50
52
  entry = build_entry(raw)
@@ -71,11 +73,17 @@ module Rails
71
73
  Array(@disabled[type.to_sym]).include?(name.to_s)
72
74
  end
73
75
 
76
+ # Hand-editable list of gems the app opts into as companions.
77
+ def enabled_gems
78
+ @enabled
79
+ end
80
+
74
81
  # Adopt the state that is not derived from installed content, so
75
82
  # rewriting the file preserves it.
76
83
  def carry_settings(other)
77
84
  @document = other.document.dup
78
85
  @disabled = other.disabled_lists.dup
86
+ @enabled = other.enabled_gems.dup
79
87
  self
80
88
  end
81
89
 
@@ -103,6 +111,7 @@ module Rails
103
111
  "version" => SCHEMA_VERSION,
104
112
  "claude_md" => carried.merge("state" => @claude_md_state),
105
113
  "disabled" => DISABLED_KEYS.each_with_object({}) { |(type, key), h| h[key] = @disabled[type] },
114
+ "enabled" => @enabled,
106
115
  "files" => @files.values.sort_by(&:path).map { |e| serialize_entry(e) }
107
116
  )
108
117
  # A nil state means no import line is being managed. Recording one anyway
@@ -136,6 +145,12 @@ module Rails
136
145
  end
137
146
  end
138
147
 
148
+ def parse_enabled(raw)
149
+ return [] unless raw.is_a?(Array)
150
+
151
+ raw.map { |name| name.to_s.strip }.reject(&:empty?).uniq
152
+ end
153
+
139
154
  def build_entry(raw)
140
155
  source_gem, source_version = split_source(raw["source"])
141
156
  Entry.new(
@@ -0,0 +1,27 @@
1
+ # Rake tasks for companion gem repos: add `require "rails/hyperdrive/skill_tasks"`
2
+ # to the Rakefile. Both tasks take an optional gemspec-path argument, e.g.
3
+ # rake "hyperdrive:skills:render[path/to/name.gemspec]".
4
+ require "rake"
5
+ require "rails/hyperdrive/canonical_skill_render"
6
+
7
+ namespace :hyperdrive do
8
+ namespace :skills do
9
+ desc "Render each SKILL.md.erb template to its canonical static SKILL.md"
10
+ task :render, [:gemspec] do |_t, args|
11
+ written = Rails::Hyperdrive::CanonicalSkillRender.write(gemspec: args[:gemspec])
12
+ written.each { |r| puts "render #{r.dest}" }
13
+ puts "no skill templates found" if written.empty?
14
+ end
15
+
16
+ desc "Fail when a canonical SKILL.md is stale relative to its template"
17
+ task :check, [:gemspec] do |_t, args|
18
+ stale = Rails::Hyperdrive::CanonicalSkillRender.stale(gemspec: args[:gemspec])
19
+ if stale.empty?
20
+ puts "canonical skill files up to date"
21
+ else
22
+ stale.each { |r| warn "stale: #{r.dest}" }
23
+ abort "#{stale.size} canonical skill file(s) stale; run `rake hyperdrive:skills:render`"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -31,6 +31,27 @@ module Rails
31
31
  end
32
32
  end
33
33
 
34
+ # Fail-open binding for the canonical render: every gem reads as present
35
+ # at any requested version, so the output includes every conditional
36
+ # branch. There is no bundle to resolve against, so gem_version is nil.
37
+ class CanonicalContext
38
+ def gem?(_name, _requirement = nil)
39
+ true
40
+ end
41
+
42
+ def any_gem?(*)
43
+ true
44
+ end
45
+
46
+ def gem_version(_name)
47
+ nil
48
+ end
49
+
50
+ def template_binding
51
+ binding
52
+ end
53
+ end
54
+
34
55
  module_function
35
56
 
36
57
  # Errors (ERB SyntaxError included) propagate; the caller decides how to
@@ -39,6 +60,10 @@ module Rails
39
60
  ERB.new(source, trim_mode: "-").result(Context.new(resolved).template_binding)
40
61
  end
41
62
 
63
+ def render_canonical(source)
64
+ ERB.new(source, trim_mode: "-").result(CanonicalContext.new.template_binding)
65
+ end
66
+
42
67
  # Gem::Requirement.new does not parse a single comma-separated string, so
43
68
  # such strings are split into separate constraints first.
44
69
  def version_matches?(requirement_str, version)
@@ -130,7 +130,8 @@ module Rails
130
130
  # Bundler is absent or refuses to resolve.
131
131
  def gem_skills_info
132
132
  require "rails/hyperdrive/bundler_artifact_discovery"
133
- ::Rails::Hyperdrive::BundlerArtifactDiscovery.discover.select(&:skill?).map do |skill|
133
+ discovered = ::Rails::Hyperdrive::BundlerArtifactDiscovery.discover(enabled_gems: enabled_gems)
134
+ discovered.select(&:skill?).map do |skill|
134
135
  {
135
136
  name: skill.name,
136
137
  gem: skill.target_gem,
@@ -143,6 +144,24 @@ module Rails
143
144
  rescue StandardError
144
145
  []
145
146
  end
147
+
148
+ def enabled_gems
149
+ require "rails/hyperdrive/install_layout"
150
+ require "rails/hyperdrive/lock_file"
151
+ root =
152
+ if @app_root
153
+ @app_root.to_s
154
+ elsif defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
155
+ ::Rails.root.to_s
156
+ end
157
+ return [] unless root
158
+
159
+ ::Rails::Hyperdrive::LockFile.load(
160
+ File.join(root, ::Rails::Hyperdrive::InstallLayout::LOCK_PATH)
161
+ ).enabled_gems
162
+ rescue StandardError
163
+ []
164
+ end
146
165
  end
147
166
  end
148
167
  end
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Hyperdrive
3
- VERSION = "0.4.0"
3
+ VERSION = "0.5.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-hyperdrive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bakaface
@@ -100,12 +100,14 @@ files:
100
100
  - lib/rails/hyperdrive/artifact_status.rb
101
101
  - lib/rails/hyperdrive/auto_install.rb
102
102
  - lib/rails/hyperdrive/bundler_artifact_discovery.rb
103
+ - lib/rails/hyperdrive/canonical_skill_render.rb
103
104
  - lib/rails/hyperdrive/claude_md_import.rb
104
105
  - lib/rails/hyperdrive/companion_discovery.rb
105
106
  - lib/rails/hyperdrive/console_executor.rb
106
107
  - lib/rails/hyperdrive/drift_verdict.rb
107
108
  - lib/rails/hyperdrive/eager_footprint.rb
108
109
  - lib/rails/hyperdrive/engine.rb
110
+ - lib/rails/hyperdrive/gem_manifest.rb
109
111
  - lib/rails/hyperdrive/index_document.rb
110
112
  - lib/rails/hyperdrive/install_layout.rb
111
113
  - lib/rails/hyperdrive/install_pipeline.rb
@@ -116,6 +118,7 @@ files:
116
118
  - lib/rails/hyperdrive/resources/skill.rb
117
119
  - lib/rails/hyperdrive/resources/stack_profile.rb
118
120
  - lib/rails/hyperdrive/safety/rack_middleware.rb
121
+ - lib/rails/hyperdrive/skill_tasks.rb
119
122
  - lib/rails/hyperdrive/skill_template.rb
120
123
  - lib/rails/hyperdrive/sql_safety.rb
121
124
  - lib/rails/hyperdrive/stack_profile.rb