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 +4 -4
- data/CHANGELOG.md +40 -1
- data/README.md +1 -1
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +69 -17
- data/lib/rails/hyperdrive/install_layout.rb +5 -1
- data/lib/rails/hyperdrive/manifest_lint.rb +7 -1
- data/lib/rails/hyperdrive/skill_template.rb +12 -2
- data/lib/rails/hyperdrive/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 65854e72d10acf60fd5140b816d8ee96bc6f573679dd74e7aeb369cbad9f572a
|
|
4
|
+
data.tar.gz: d86dd2e00fe05e063861b6a408c78dc38809a541cc39fa63a6ab5ca709e88bc3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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 =
|
|
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
|
|
115
|
-
#
|
|
116
|
-
# filename.
|
|
117
|
-
#
|
|
118
|
-
#
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
|
216
|
-
#
|
|
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,
|
|
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.
|
|
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
|
|
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
|