rails-hyperdrive 0.7.0 → 0.8.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: 19c65882e51a2efb328a453fec5a561af7b9b012e09623f39eba055b7898bcab
4
- data.tar.gz: e2bdf0a95fe1ed81db2bb56f0f649c924f502b28a7577f6931778ebb95ca81c0
3
+ metadata.gz: 65854e72d10acf60fd5140b816d8ee96bc6f573679dd74e7aeb369cbad9f572a
4
+ data.tar.gz: d86dd2e00fe05e063861b6a408c78dc38809a541cc39fa63a6ab5ca709e88bc3
5
5
  SHA512:
6
- metadata.gz: e2f083bf83220b970052d44b95c11f6d69b26a58801094cdce349de803b4497983925ba4082d1167e17226d0e5511ac328dc644d71239f7c5694c853f49f6dad
7
- data.tar.gz: 955afb3426b981ae68136de0f8f3e1d8ecceb70abcb7a56579b7aaeeb00824915cc273b00244315ba2d49d94a8d0a73bfbe7e68d5c5750a5830a5e1fbaeba52e
6
+ metadata.gz: a4a279ebaaf70cf2cd43d050d8d031b0ddc45f0e7790e49954dc4f8c96e1a0733c61c3bb4a1ee365ed076992f29a47f00a40707c964328d11f63b5130cd1ce28
7
+ data.tar.gz: c84c36f0563a52ae2938a998167f2287d68ec586f355794b48c6b79887446fac5cc127f20e0a260613aaeb43f8d9d3187fd198b72a51f3ab7482b20ab5a84f41
data/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-08-26
11
+
12
+ ### Added
13
+
14
+ - **ERB templates for guidelines, agents, and commands.** A companion can ship a
15
+ flat artifact as `<name>.md.erb` directly in its own root
16
+ (`lib/<gem_name>/hyperdrive/guidelines/`, `agents/`, `commands/`, or an
17
+ `agents_dir:`/`commands_dir:` override). It renders at discovery against the
18
+ app's resolved bundle with the same helpers skills use — `gem?`, `any_gem?`,
19
+ `gem_version`, `canonical_render?` — and installs as `<name>.md`: a
20
+ guideline's frontmatter is parsed from the render and then stripped, a
21
+ templated command takes its identity from the rendered stem
22
+ (`analyze.md.erb` → `/analyze`, prefixed by `command_prefix:` as usual), and
23
+ the rendered bytes are what the lock hashes, so a re-render is an ordinary
24
+ upstream delivery through the existing drift, sidecar, and merge machinery.
25
+ There is no template/content pairing for flat kinds and no separate template
26
+ root: Claude Code plugins glob `*.md`, so a template is invisible to them.
27
+ A static `<name>.md` beats a template rendering to the same filename anywhere
28
+ in the kind's roots, always with a warning; a manifest entry may key the
29
+ artifact by either spelling (`reviewer.md.erb` or `reviewer.md`), the shipped
30
+ spelling winning with a warning when both appear, and
31
+ `rake hyperdrive:manifest:check` fails on that ambiguity. A convention-path
32
+ guideline template opts a gem in as a companion, like its static twin.
33
+ Declare a gem-wide `hyperdrive_version: ">= 0.8"` when shipping templated flat
34
+ artifacts: earlier installers never look for `*.md.erb` outside skill
35
+ directories, so the fence is what turns their silence into "upgrade
36
+ rails-hyperdrive to install it".
37
+ - `canonical_render?`, a fourth skill-template ERB helper: `true` in the
38
+ author-side canonical render (`rake hyperdrive:skills:render`/`check`) and
39
+ `false` when rendering into an app, so one template can serve both channels
40
+ where the same content has to read differently — a companion that also ships
41
+ as a Claude Code plugin, say, whose command spellings differ from the
42
+ hyperdrive-installed ones. Unlike the bundle predicates it is deterministic in
43
+ both bindings, so `if`/`else`/`unless` on it is safe. A template using it
44
+ should declare `hyperdrive_version: ">= 0.8"` in its manifest entry (or
45
+ gem-wide), so an older installer reports "upgrade rails-hyperdrive" rather
46
+ than skipping the skill with a template-render failure.
47
+
10
48
  ## [0.7.0] - 2026-08-26
11
49
 
12
50
  ### Added
@@ -716,7 +754,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
716
754
  - `hyperdrive:init` generator that installs architecture skills and auto-discovers
717
755
  per-gem skills.
718
756
 
719
- [Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.7.0...HEAD
757
+ [Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.8.0...HEAD
758
+ [0.8.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.8.0
720
759
  [0.7.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.7.0
721
760
  [0.6.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.6.0
722
761
  [0.5.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.5.0
data/README.md CHANGED
@@ -198,7 +198,7 @@ lib/<gem_name>/hyperdrive/guidelines/<name>.md # guideline (flat file)
198
198
 
199
199
  `<gem_name>` is your gem's name exactly as published, dashes and all — `rails-hyperdrive-sidekiq` ships guidelines under `lib/rails-hyperdrive-sidekiq/hyperdrive/guidelines/`, not `lib/rails/hyperdrive/sidekiq/`.
200
200
 
201
- Top-level `skills/`, `agents/`, and `commands/` are the tool-agnostic face of your gem — the same sibling layout `.claude/` has, so relative links between them keep resolving once installed. They are readable by skills.sh and plain git-clone consumers as well as hyperdrive, and are scanned once your gem has opted in (below). `lib/<gem_name>/hyperdrive/` is the hyperdrive-specific root: guidelines, and ERB skill templates (`SKILL.md.erb`, which must stay out of `skills/` so raw ERB never reaches generic consumers). Plain skills shipped under it remain scanned as well.
201
+ Top-level `skills/`, `agents/`, and `commands/` are the tool-agnostic face of your gem — the same sibling layout `.claude/` has, so relative links between them keep resolving once installed. They are readable by skills.sh and plain git-clone consumers as well as hyperdrive, and are scanned once your gem has opted in (below). `lib/<gem_name>/hyperdrive/` is the hyperdrive-specific root: guidelines, and ERB skill templates (`SKILL.md.erb`, which must stay out of `skills/` so raw ERB never reaches generic consumers). Plain skills shipped under it remain scanned as well. Guidelines, agents, and commands may ship as `<name>.md.erb` in their own roots — rendered against the app's bundle at discovery and installed as `<name>.md`, with a static file of the same name taking precedence.
202
202
 
203
203
  Frontmatter is pure skills.sh: only `name` and `description` are read, so a skill repo's content integrates without modification:
204
204
 
@@ -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)
@@ -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,
@@ -22,7 +22,7 @@ module Rails
22
22
  # identified and validated, and where each target tool installs it.
23
23
  Kind = Struct.new(
24
24
  :type, :shape, :section, :key_label, :shipped_label, :dir_key, :prefix_key,
25
- :convention_roots, :identity, :frontmatter, :install_body,
25
+ :convention_roots, :identity, :frontmatter, :install_body, :erb,
26
26
  :collision_rewrites_name, :eager, :name_from_dest, :dests,
27
27
  keyword_init: true
28
28
  ) do
@@ -63,6 +63,7 @@ module Rails
63
63
  identity: :frontmatter,
64
64
  frontmatter: :required,
65
65
  install_body: :verbatim,
66
+ erb: true,
66
67
  collision_rewrites_name: true,
67
68
  eager: false,
68
69
  name_from_dest: ->(dest) { File.basename(File.dirname(dest)) },
@@ -89,6 +90,7 @@ module Rails
89
90
  identity: :frontmatter,
90
91
  frontmatter: :required,
91
92
  install_body: :strip_frontmatter,
93
+ erb: true,
92
94
  collision_rewrites_name: false,
93
95
  eager: true,
94
96
  name_from_dest: ->(dest) { File.basename(dest, ".md") },
@@ -111,6 +113,7 @@ module Rails
111
113
  identity: :frontmatter,
112
114
  frontmatter: :required,
113
115
  install_body: :verbatim,
116
+ erb: true,
114
117
  collision_rewrites_name: true,
115
118
  eager: false,
116
119
  name_from_dest: ->(dest) { File.basename(dest, ".md") },
@@ -128,6 +131,7 @@ module Rails
128
131
  identity: :filename_stem,
129
132
  frontmatter: :optional,
130
133
  install_body: :verbatim,
134
+ erb: true,
131
135
  collision_rewrites_name: false,
132
136
  eager: false,
133
137
  name_from_dest: ->(dest) { File.basename(dest, ".md") },
@@ -102,9 +102,14 @@ module Rails
102
102
  entries.each do |key, entry|
103
103
  next if Array(kind.prefix_key).include?(key)
104
104
 
105
+ face = BundlerArtifactDiscovery.target_path(key)
105
106
  unless shipped.include?(key)
106
107
  problems << "#{kind.section} entry '#{key}' names no shipped #{kind.shipped_label}"
107
108
  end
109
+ if face != key && entries.key?(face)
110
+ problems << "#{kind.section} entry '#{key}': '#{face}' keys the same file; keep one spelling"
111
+ end
112
+
108
113
  unless entry.is_a?(Hash)
109
114
  problems << "#{kind.section} entry '#{key}' must be a map of gating keys"
110
115
  next
@@ -218,8 +223,9 @@ module Rails
218
223
  end
219
224
  end
220
225
 
226
+ # Both spellings of a template-shipped file, since either gates it.
221
227
  def shipped_flat(repo_spec, kind, manifest)
222
- BundlerArtifactDiscovery.flat_paths(repo_spec, kind, manifest: manifest).map { |p| File.basename(p) }
228
+ BundlerArtifactDiscovery.flat_keys(repo_spec, kind, manifest: manifest)
223
229
  end
224
230
 
225
231
  private_class_method :missing_manifest, :problems_in, :parse_root, :each_entry, :check_section_settings,
@@ -3,7 +3,7 @@ require "erb"
3
3
  module Rails
4
4
  module Hyperdrive
5
5
  # Renders a skill's *.md.erb sources against the app's resolved bundle.
6
- # The three helpers are the only supported template API, but the binding is
6
+ # The four helpers are the only supported template API, but the binding is
7
7
  # an ordinary object rather than a sandbox: a template runs arbitrary Ruby
8
8
  # with the privileges of whoever triggered discovery.
9
9
  module SkillTemplate
@@ -27,6 +27,10 @@ module Rails
27
27
  version&.to_s
28
28
  end
29
29
 
30
+ def canonical_render?
31
+ false
32
+ end
33
+
30
34
  def template_binding
31
35
  binding
32
36
  end
@@ -35,7 +39,9 @@ module Rails
35
39
  # Canonical render binding: every gem reads as present at any requested
36
40
  # version, so an if/elsif/else contributes only its first branch and
37
41
  # templates meant for this render must use additive if blocks. There is
38
- # no bundle to resolve against, so gem_version is nil.
42
+ # no bundle to resolve against, so gem_version is nil. canonical_render?
43
+ # is the exception: it is deterministic in both bindings, so branching on
44
+ # it is safe.
39
45
  class CanonicalContext
40
46
  def gem?(_name, _requirement = nil)
41
47
  true
@@ -49,6 +55,10 @@ module Rails
49
55
  nil
50
56
  end
51
57
 
58
+ def canonical_render?
59
+ true
60
+ end
61
+
52
62
  def template_binding
53
63
  binding
54
64
  end
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Hyperdrive
3
- VERSION = "0.7.0"
3
+ VERSION = "0.8.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.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bakaface