rails-hyperdrive 0.3.0 → 0.4.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 +66 -1
- data/README.md +77 -109
- data/lib/generators/hyperdrive/install/USAGE +3 -2
- data/lib/generators/hyperdrive/sync/USAGE +17 -3
- data/lib/generators/hyperdrive/sync/sync_generator.rb +16 -1
- data/lib/rails/hyperdrive/ancestor_locator.rb +76 -0
- data/lib/rails/hyperdrive/artifact_status.rb +1 -1
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +28 -1
- data/lib/rails/hyperdrive/drift_verdict.rb +9 -14
- data/lib/rails/hyperdrive/install_layout.rb +4 -0
- data/lib/rails/hyperdrive/install_pipeline.rb +186 -30
- data/lib/rails/hyperdrive/resources/stack_profile.rb +1 -1
- data/lib/rails/hyperdrive/stack_profile.rb +11 -39
- data/lib/rails/hyperdrive/three_way_merge.rb +47 -0
- data/lib/rails/hyperdrive/tools/describe_app.rb +1 -1
- data/lib/rails/hyperdrive/version.rb +1 -1
- data/lib/tasks/hyperdrive.rake +1 -1
- metadata +3 -3
- data/lib/rails/hyperdrive/audit_header.rb +0 -83
- data/lib/rails/hyperdrive/data/gem_categories.yml +0 -52
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3fbb6e0845e6194c6b33334e2bd03db6629cd61a8bfd973f8be3ce7b82fd1be
|
|
4
|
+
data.tar.gz: 8f259097744b8729ef71756df833809ff315305bf52a822ca1e40fa94298bc26
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3986cc09cecd60a44ecc9899be526c3db981d635a8bc458de956965b227fe51a2d3d4efc8e6da03f8016fc83320d59235d975a2ccdb22d4e86d6d75d7296e970
|
|
7
|
+
data.tar.gz: 4524e73cd275471bc2e555ff6845b31b3e4b96f75839bd5629b59611522e85d3dd224c4cc5b197f9a38d1ecf66a6fa9961d9ea60567c9626bb90ff46cc2825de
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-08-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `hyperdrive:sync --sidecar`: when an installed file is locally modified and
|
|
15
|
+
its gem ships something new, the new upstream body is delivered next to it
|
|
16
|
+
as `<file>.new` instead of being skipped. The live
|
|
17
|
+
file is never touched; `mv <file>.new <file>` accepts the upstream
|
|
18
|
+
wholesale. The lockfile records the delivered upstream, so the same version
|
|
19
|
+
is offered exactly once, and a delivered-but-unresolved file no longer nags
|
|
20
|
+
from `bundle install`.
|
|
21
|
+
- `hyperdrive:sync --merge`: same as `--sidecar`, but first attempts a git
|
|
22
|
+
three-way merge of the local edits with the upstream change, using the
|
|
23
|
+
previously installed gem version (found in the installed gem directories,
|
|
24
|
+
content-verified against the lock) as the ancestor. Only a clean merge is
|
|
25
|
+
written to the live file — a conflict, a missing ancestor, a missing `git`,
|
|
26
|
+
binary content, or an earlier delivery still unresolved at `<file>.new` all
|
|
27
|
+
fall back to the sidecar delivery, so conflict markers never reach a live
|
|
28
|
+
file and a pending delivery is never merged over.
|
|
29
|
+
- Leftover sidecars are swept: whenever a sync writes or verifies the live
|
|
30
|
+
file, a `<file>.new` still matching a delivered upstream is removed, and
|
|
31
|
+
one you edited is warned about and left alone.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- **BREAKING:** the stack profile (`describe_app` MCP tool and the
|
|
36
|
+
`hyperdrive://stack-profile` resource) no longer categorizes gems into
|
|
37
|
+
`test` / `jobs` / `frontend` / `auth` / `authz` / `db_gems` buckets. Those
|
|
38
|
+
keys are replaced by `direct_dependencies` — the app's declared gems (the
|
|
39
|
+
lockfile's `DEPENDENCIES` section) with their resolved versions. The old
|
|
40
|
+
buckets matched a hand-curated gem list against the *resolved* dependency
|
|
41
|
+
set, so every Rails app reported transitive `minitest` as a chosen test
|
|
42
|
+
framework
|
|
43
|
+
([#2](https://github.com/rails-hyperdrive/rails-hyperdrive/issues/2));
|
|
44
|
+
raw direct dependencies carry no such editorializing and
|
|
45
|
+
need no curated list to go stale. `rails`, `ruby`, `database`, and
|
|
46
|
+
`gem_skills` are unchanged.
|
|
47
|
+
|
|
48
|
+
- Installed skills no longer carry the installer-only frontmatter keys
|
|
49
|
+
(`gem:`, `versions:`, `conditional:`). They are discovery-time inputs with
|
|
50
|
+
no post-install reader, and the `conditional:` map referenced shipped paths
|
|
51
|
+
that gating and ERB rendering could leave pointing at files absent from
|
|
52
|
+
disk — dead weight in the agent's context window at every skill invocation.
|
|
53
|
+
The installed frontmatter now holds only what the runtime reads (`name:`,
|
|
54
|
+
`description:`, any extra keys like `allowed-tools:`). Because the
|
|
55
|
+
install-ready body changes, the next
|
|
56
|
+
`hyperdrive:sync` rewrites each unedited installed skill once; locally
|
|
57
|
+
edited skills are skipped with the usual warning.
|
|
58
|
+
- The skip warning for a locally-modified file now names all three
|
|
59
|
+
reconciliation flags (`--merge`, `--sidecar`, `--overwrite`).
|
|
60
|
+
- Orphan reports now say "no longer shipped by \<source\>" instead of
|
|
61
|
+
"source \<source\> no longer in bundle" — an artifact is also orphaned when
|
|
62
|
+
its gem is still bundled but stopped shipping it.
|
|
63
|
+
|
|
64
|
+
### Removed
|
|
65
|
+
|
|
66
|
+
- Audit headers. Installed skills and guidelines no longer carry the
|
|
67
|
+
`# hyperdrive: source=...` / `<!-- hyperdrive: ... -->` comment block —
|
|
68
|
+
every installed file now lands byte-identical to its install-ready body
|
|
69
|
+
(sidecar `.new` deliveries and merge results included). The header
|
|
70
|
+
duplicated what the git-tracked `.hyperdrive/lock.yml` already records per
|
|
71
|
+
file (`source`, `source_sha`, `installed_at`) and was loaded into the
|
|
72
|
+
agent's context on every skill invocation (and eagerly, for guidelines).
|
|
73
|
+
|
|
10
74
|
## [0.3.0] - 2026-08-04
|
|
11
75
|
|
|
12
76
|
### Added
|
|
@@ -289,7 +353,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
289
353
|
- `hyperdrive:init` generator that installs architecture skills and auto-discovers
|
|
290
354
|
per-gem skills.
|
|
291
355
|
|
|
292
|
-
[Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.
|
|
356
|
+
[Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.4.0...HEAD
|
|
357
|
+
[0.4.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.4.0
|
|
293
358
|
[0.3.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.3.0
|
|
294
359
|
[0.2.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.2.0
|
|
295
360
|
[0.1.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/rails-hyperdrive/rails-hyperdrive/main/docs/logo-wide.png" alt="" width="480">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
# Rails Hyperdrive
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
**Live introspection and stack-matched knowledge for AI coding agents, straight from your Rails app.**
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
[](https://rubygems.org/gems/rails-hyperdrive)
|
|
10
|
+
[](https://github.com/rails-hyperdrive/rails-hyperdrive/actions/workflows/ci.yml)
|
|
11
|
+
[](LICENSE.txt)
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
- **Guidelines** — eager, always in context via `@`-include from `CLAUDE.md`. Declarative ("this app uses Pundit, not CanCanCan"). Installed to `.claude/hyperdrive/guidelines/<name>.md`.
|
|
13
|
+
Rails Hyperdrive is a development-only Rails engine for working on Rails apps with AI coding agents. It gives the agent two things it can't get from source alone — live answers from the booted app, and guidance specific to the gems and versions in the bundle:
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
- **Live introspection.** The engine mounts an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server at `http://localhost:3000/_hyperdrive/mcp` with **8 tools** that answer from the running app itself: eval Ruby, query the DB (read-only), tail logs, list models and routes, jump to source, look up docs, snapshot the stack. The agent asks the router instead of grepping `routes.rb`, and reads the live schema instead of replaying migrations.
|
|
16
|
+
- **Stack-specific knowledge.** `bin/rails hyperdrive:init` discovers **skills** and **guidelines** shipped by companion gems and installs only the ones matching your Gemfile: guidance targeting Sidekiq, for example, lands only if your app bundles Sidekiq, at a version the guidance covers.
|
|
13
17
|
|
|
14
|
-
|
|
18
|
+
**rails-hyperdrive is the mechanism; companion gems are the content.** The gem itself ships no skills or guidelines — only the contract and the discovery/install engine. Content comes from companion gems, conventionally named `rails-hyperdrive-<library>` (e.g. `rails-hyperdrive-sidekiq`), following the [RuboCop ecosystem](https://github.com/rubocop/rubocop) precedent.
|
|
15
19
|
|
|
16
20
|
---
|
|
17
21
|
|
|
18
|
-
##
|
|
22
|
+
## Quick start
|
|
19
23
|
|
|
20
24
|
```bash
|
|
21
25
|
# 1. Add the dev gem
|
|
@@ -53,17 +57,13 @@ $ bin/dev
|
|
|
53
57
|
# → agent has 8 tools, the eager guidelines (via CLAUDE.md), and the lazy skills
|
|
54
58
|
```
|
|
55
59
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
Run `bin/rails hyperdrive:sync` any time (e.g. after `bundle update` or adding a companion gem) to refresh installed content to the current bundle. It touches no bootstrap artifact and leaves locally-modified files untouched (skip + warn); pass `--overwrite` to restore them to the gem-shipped content.
|
|
59
|
-
|
|
60
|
-
Run `hyperdrive:discover` to find companion gems published for your stack that you haven't installed yet — it queries rubygems (read-only, results cached for 24h; `--refresh` re-queries) and prints the `bundle add` lines to run, then run `bin/rails hyperdrive:sync`. It never touches your Gemfile or makes network calls unless you invoke it.
|
|
60
|
+
That's it. No API keys, no config files to write, no per-project setup beyond the generator.
|
|
61
61
|
|
|
62
62
|
---
|
|
63
63
|
|
|
64
|
-
## What
|
|
64
|
+
## What your agent gets
|
|
65
65
|
|
|
66
|
-
### MCP tools
|
|
66
|
+
### 8 MCP tools
|
|
67
67
|
|
|
68
68
|
| # | Tool | Purpose |
|
|
69
69
|
|---|------|---------|
|
|
@@ -73,30 +73,66 @@ Run `hyperdrive:discover` to find companion gems published for your stack that y
|
|
|
73
73
|
| 4 | `list_models` | List Active Record model classes with columns/validations/associations |
|
|
74
74
|
| 5 | `locate_source` | Resolve `Const` / `Const#method` / `Const.method` / `dep:<gem>` to a file:line |
|
|
75
75
|
| 6 | `lookup_doc` | Look up RDoc for a symbol (via `ri`) |
|
|
76
|
-
| 7 | `describe_app` | Snapshot: Rails/Ruby/DB versions +
|
|
76
|
+
| 7 | `describe_app` | Snapshot: Rails/Ruby/DB versions + direct gem dependencies |
|
|
77
77
|
| 8 | `list_routes` | All routes: HTTP verb, path, controller#action, named route |
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
Plus two MCP resources: `hyperdrive://stack-profile` (JSON snapshot of your resolved stack) and `hyperdrive://skills/{name}` (the markdown body of each installed skill).
|
|
80
|
+
|
|
81
|
+
### Two kinds of knowledge
|
|
82
|
+
|
|
83
|
+
Companion gems ship two artifact types, tuned for how agents consume context:
|
|
84
|
+
|
|
85
|
+
- **Skills** — *lazy*. Loaded on demand via Claude Code's native description matcher. Procedural knowledge: "how to write an idempotent Sidekiq job". Installed to `.claude/skills/<name>/SKILL.md`, optionally with supporting files (references, examples, workflows) alongside.
|
|
86
|
+
- **Guidelines** — *eager*. Always in context via a single `@`-include from `CLAUDE.md`. Declarative facts: "this app uses ActionPolicy, not Pundit". Installed to `.claude/hyperdrive/guidelines/<name>.md`.
|
|
87
|
+
|
|
88
|
+
Every artifact declares which gem it targets and at which versions, so what installs is exactly what matches your `Gemfile.lock` — nothing generic, nothing stale.
|
|
89
|
+
|
|
90
|
+
With no companion gems, `hyperdrive:init` sets up just the server plumbing (`.mcp.json`, the engine mount, the lockfile) and puts **nothing** into your agent's context window. Zero context cost until you opt in.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Staying in sync
|
|
95
|
+
|
|
96
|
+
**After `bundle install` — automatic.** `hyperdrive:init` registers the [`bundler-rails-hyperdrive`](bundler-rails-hyperdrive/) Bundler plugin in your Gemfile. From then on, `bundle add rails-hyperdrive-<library>` lands the companion's artifacts on that very `bundle install` — no extra command. The plugin is additive only (it never touches an existing file); version bumps and orphaned artifacts are only reported, with a pointer to `hyperdrive:sync`.
|
|
97
|
+
|
|
98
|
+
**`bin/rails hyperdrive:sync` — on demand.** Run it any time (e.g. after `bundle update`) to refresh installed content to the current bundle. It touches no bootstrap artifact and leaves locally-modified files untouched (skip + warn). When you *have* edited an installed file and its gem ships a new version, three mutually-exclusive flags reconcile the two:
|
|
99
|
+
|
|
100
|
+
| Strategy | What happens to the live file | What happens to your edits |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `--merge` | Rewritten with a git three-way merge when it applies cleanly; otherwise untouched and the upstream lands as a `--sidecar` delivery | Kept — a merge that would need conflict markers falls back to the sidecar instead, so nothing half-merged ever goes live |
|
|
103
|
+
| `--sidecar` | Untouched; the new upstream body is written next to it as `<file>.new` | Kept, byte-for-byte |
|
|
104
|
+
| `--overwrite` | Restored to the gem-shipped content | Discarded |
|
|
105
|
+
|
|
106
|
+
A sidecar is inert — Claude Code loads only `SKILL.md` and the `index.md` `@`-lines, never a `.new` file — and it shows up in `git status` as your prompt to resolve. Resolve it by folding what you want into the live file and deleting the `.new`, or `mv <file>.new <file>` to accept the upstream wholesale. Either way the lockfile already records that delivery, so the next sync doesn't re-offer the same version (and a leftover sidecar you haven't touched is cleaned up once the live file catches up). `--merge` needs the previously installed gem version still present on disk to reconstruct the merge ancestor; when it isn't (CI, after `gem cleanup`), it degrades to the sidecar with a note saying why.
|
|
80
107
|
|
|
81
|
-
|
|
82
|
-
- `hyperdrive://skills/{name}` — markdown body of each installed skill
|
|
108
|
+
The sidecar pair is also how an AI coding agent reconciles for you, with no extra machinery: run `bin/rails hyperdrive:sync --sidecar`, have the agent merge the live/`.new` pair semantically (it has both full texts), then delete the sidecar.
|
|
83
109
|
|
|
84
|
-
|
|
110
|
+
**`bin/rails hyperdrive:discover` — find what you're missing.** Queries rubygems for companion gems published for your stack that you haven't installed yet, and prints the `bundle add` lines to run. Read-only, results cached for 24h (`--refresh` re-queries), and it never touches your Gemfile or makes network calls unless you invoke it.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## You stay in charge
|
|
115
|
+
|
|
116
|
+
### Everything lands git-tracked
|
|
85
117
|
|
|
86
118
|
```
|
|
87
119
|
CLAUDE.md # user-owned; ONE injected line: @.claude/hyperdrive/index.md
|
|
88
120
|
.claude/hyperdrive/
|
|
89
121
|
index.md # managed aggregator: @guidelines/<name>.md
|
|
90
|
-
guidelines/<name>.md # companion-shipped, frontmatter stripped
|
|
122
|
+
guidelines/<name>.md # companion-shipped, frontmatter stripped
|
|
91
123
|
.claude/skills/<name>/
|
|
92
|
-
SKILL.md # companion-shipped, frontmatter kept
|
|
124
|
+
SKILL.md # companion-shipped, frontmatter kept minus installer keys
|
|
93
125
|
<supporting files> # optional companion-shipped extras, installed as shipped (*.md.erb rendered)
|
|
94
126
|
.hyperdrive/lock.yml # git-tracked manifest (source gem, version, content hash)
|
|
95
127
|
```
|
|
96
128
|
|
|
97
|
-
`
|
|
129
|
+
A `git diff` is where you review what a companion gem added. The install summary names each artifact's source gem and version, and every installed file is hashed and attributed to its source in the git-tracked `.hyperdrive/lock.yml` — the files themselves land byte-identical to what the gem ships, with nothing injected. `hyperdrive:init` warns if your app gitignores these paths, since that empties the diff without changing what reaches the agent. The `hyperdrive:discover` cache is the one file rails-hyperdrive adds to `.gitignore`.
|
|
98
130
|
|
|
99
|
-
|
|
131
|
+
`CLAUDE.md` and `index.md` are the **eager chain** — they exist only because a companion gem ships a guideline, and both go when the last one leaves the bundle (the guideline file itself is left on disk and reported as an orphan).
|
|
132
|
+
|
|
133
|
+
### Your edits win
|
|
134
|
+
|
|
135
|
+
Installed files are yours to modify. The lockfile hash tells the installer whether a file is still gem-pristine: unedited files are refreshed on upgrade, edited files are skipped with a warning — never silently overwritten. `hyperdrive:sync --overwrite` is the explicit way back to gem-shipped content.
|
|
100
136
|
|
|
101
137
|
### Turning off a single artifact
|
|
102
138
|
|
|
@@ -114,28 +150,24 @@ A disabled artifact is never installed, and one already on disk is removed on th
|
|
|
114
150
|
|
|
115
151
|
The list is yours to edit; the generator only reads it and carries it forward. Delete a name to get the artifact back on the next run. When two companion gems ship the same artifact name, both install under a `<name>--<source-gem>` suffix — the plain name disables both, the suffixed name disables one.
|
|
116
152
|
|
|
117
|
-
To skip installed content wholesale instead, pass `--skip-content`.
|
|
153
|
+
To skip installed content wholesale instead, pass `--skip-content` to `hyperdrive:init`.
|
|
118
154
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
A companion gem ships artifacts under:
|
|
155
|
+
---
|
|
122
156
|
|
|
123
|
-
|
|
124
|
-
<gem-source>/lib/<gem_name>/hyperdrive/skills/<name>/SKILL.md # skill (dir-per-skill)
|
|
125
|
-
<gem-source>/lib/<gem_name>/hyperdrive/guidelines/<name>.md # guideline (flat file)
|
|
126
|
-
```
|
|
157
|
+
## Safety
|
|
127
158
|
|
|
128
|
-
|
|
159
|
+
Rails Hyperdrive is **dev-only**, enforced in depth: the engine refuses to handle requests outside `Rails.env.development?`, enforces an origin allowlist (`localhost`, `127.0.0.1`, `[::1]`), and every tool re-checks the dev guard on invocation. `run_sql` accepts read-only statements and refuses anything else. See [SECURITY.md](SECURITY.md).
|
|
129
160
|
|
|
130
|
-
|
|
131
|
-
spec.metadata["rails_hyperdrive_skills_dir"] = "extra/skills" # optional; relative to the gem root
|
|
132
|
-
```
|
|
161
|
+
---
|
|
133
162
|
|
|
134
|
-
|
|
163
|
+
## Build a companion gem
|
|
135
164
|
|
|
136
|
-
|
|
165
|
+
Ship markdown at a convention path, declare what it targets, publish. That's the whole contract:
|
|
137
166
|
|
|
138
|
-
|
|
167
|
+
```
|
|
168
|
+
lib/<gem_name>/hyperdrive/skills/<name>/SKILL.md # skill (dir-per-skill, may ship supporting files)
|
|
169
|
+
lib/<gem_name>/hyperdrive/guidelines/<name>.md # guideline (flat file)
|
|
170
|
+
```
|
|
139
171
|
|
|
140
172
|
```yaml
|
|
141
173
|
---
|
|
@@ -146,83 +178,19 @@ versions: ">= 7.0, < 9.0" # Gem::Requirement matched against the target
|
|
|
146
178
|
---
|
|
147
179
|
```
|
|
148
180
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
`versions:` accepts a single comma-separated string (`">= 7.0, < 9.0"`), a YAML list (`[">= 7.0", "< 9.0"]`), or — for multi-target artifacts — a map keyed by gem name.
|
|
152
|
-
|
|
153
|
-
`gem:` names the **targets** (each must be present in the bundle; its resolved version is matched against `versions:`). Use `railties` for "every Rails app" or the quoted `"*"` for "always applicable" (it must be quoted — bare `*` is a YAML alias and the file is skipped). `hyperdrive:init` discovers every such file across the bundle, version-matches it, and installs it with an audit header naming `source`, `sha256`, and `installed_at`. Guidelines are installed with their frontmatter stripped (they are `@`-included eagerly). When two gems ship a same-named artifact, both install, each postfixed by source gem.
|
|
154
|
-
|
|
155
|
-
One artifact can cover several interchangeable libraries — write `gem:` as a comma-separated string or a YAML list, and it installs when **any** listed target is bundled at a satisfying version. `"*"` anywhere in the list makes the artifact universal. Give `versions:` a map keyed by gem name when the targets do not share a version cycle; targets the map omits are unconstrained.
|
|
156
|
-
|
|
157
|
-
```yaml
|
|
158
|
-
---
|
|
159
|
-
name: jobs-conventions
|
|
160
|
-
description: Background job conventions.
|
|
161
|
-
gem: [sidekiq, solid_queue, good_job]
|
|
162
|
-
versions:
|
|
163
|
-
sidekiq: ">= 7.0"
|
|
164
|
-
solid_queue: ">= 1.0"
|
|
165
|
-
---
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
Draw the listed targets from the gem's own `hyperdrive_targets` (below): the gem-level declaration decides whether a companion is suggested at all, and an artifact naming a target the gemspec omits is unreachable for apps that have only that target.
|
|
169
|
-
|
|
170
|
-
#### Gem-conditional skill content
|
|
171
|
-
|
|
172
|
-
A multi-file skill can condition parts of itself on the app's bundle, so one skill tree serves apps with different gem sets. Both mechanisms are evaluated at discovery time, against the same resolved bundle that gates whole artifacts.
|
|
173
|
-
|
|
174
|
-
**Per-file gating.** A `conditional:` map in `SKILL.md` frontmatter gates individual supporting files. Keys are dir-relative shipped paths; values take the same `gem:`/`versions:` forms as the artifact-level fields (single target, comma-separated string, YAML list, per-target `versions:` map, `"*"`), and the file installs when **any** listed target is bundled at a satisfying version. Unlike the artifact level, `versions:` is optional here — omitted means unconstrained. Files the map doesn't mention install unconditionally, and the supporting files themselves stay byte-identical to upstream — the condition lives entirely out-of-band.
|
|
175
|
-
|
|
176
|
-
```yaml
|
|
177
|
-
---
|
|
178
|
-
name: layered-rails
|
|
179
|
-
description: Layered architecture conventions.
|
|
180
|
-
gem: railties
|
|
181
|
-
versions: ">= 7.2"
|
|
182
|
-
conditional:
|
|
183
|
-
references/gems/alba.md:
|
|
184
|
-
gem: alba
|
|
185
|
-
references/gems/jobs.md:
|
|
186
|
-
gem: [sidekiq, solid_queue]
|
|
187
|
-
versions:
|
|
188
|
-
sidekiq: ">= 7.0"
|
|
189
|
-
---
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
A malformed condition **fails open**: the file installs unconditionally and the problem is reported with the other discovery warnings — a surplus reference file is harmless, a missing one breaks links from `SKILL.md`. A key naming no shipped file, or naming `SKILL.md` itself (the artifact-level `gem:`/`versions:` gate the whole skill), is warned about and ignored. The `conditional:` key ships through to the installed frontmatter unchanged.
|
|
193
|
-
|
|
194
|
-
**ERB-templated markdown.** A file named `*.md.erb` in a skill directory — including `SKILL.md.erb` in place of `SKILL.md` — is rendered at install time and lands as plain `.md` (the `.erb` suffix is dropped; a `SKILL.md.erb` defines a skill exactly like `SKILL.md`, with frontmatter read from the rendered output). Templates see a sealed binding of exactly three helpers over the resolved bundle, nothing else:
|
|
195
|
-
|
|
196
|
-
- `gem?("name")` / `gem?("name", ">= 2.0")` — is the gem bundled (at a satisfying version)?
|
|
197
|
-
- `any_gem?("a", "b", …)` — is any of these bundled?
|
|
198
|
-
- `gem_version("name")` — the resolved version as a String, or `nil`.
|
|
199
|
-
|
|
200
|
-
Rendering uses ERB's trim mode, so `<%- if gem?("alba") -%>` … `<%- end -%>` control lines leave no blank lines behind. A template that fails to render is skipped with a warning (the whole skill, when it's `SKILL.md.erb`); when a plain file and a template would land at the same path, the plain file wins with a warning. `conditional:` keys refer to templates by their shipped `x.md.erb` name and gate them before rendering. Guidelines get no ERB support.
|
|
201
|
-
|
|
202
|
-
Use ERB sparingly — condition reference manuals via `conditional:` and wrap link-table rows that point at gated files, but keep "consider adopting gem X" recommendations unconditional. An all-wrapped `.md.erb` renders to an empty file; to omit a file entirely, gate it with `conditional:` instead.
|
|
203
|
-
|
|
204
|
-
Gated files appear and disappear as the bundle changes: `hyperdrive:init`/`hyperdrive:sync` install newly gated-in files and remove unedited gated-out ones. The auto top-up after `bundle install` adds newly gated-in files but never removes anything and never rewrites an already-installed file, so removals and re-rendered template output wait for the next `hyperdrive:sync`.
|
|
205
|
-
|
|
206
|
-
Discovery never raises. An artifact with missing or malformed frontmatter, a missing required field, no declared target in the bundle, or every bundled target resolving outside `versions:` is skipped, and the reason is collected. `hyperdrive:init` and `hyperdrive:sync` print the collected reasons at the end of the run, under a yellow `warn` line reading `discovery skipped N artifact(s):`. A companion whose artifacts all fail therefore installs nothing and reports it only there — read that section first when a gem you expected to contribute produces no files.
|
|
207
|
-
|
|
208
|
-
To be discoverable by `hyperdrive:discover` **before** it is installed, a companion also declares gemspec metadata (read remotely from rubygems, so the frontmatter inside the gem isn't visible yet):
|
|
181
|
+
And to be suggested by `hyperdrive:discover` before anyone installs you:
|
|
209
182
|
|
|
210
183
|
```ruby
|
|
211
|
-
spec.metadata["rails_hyperdrive_targets"]
|
|
212
|
-
spec.metadata["rails_hyperdrive_artifacts"] = "guideline,skill" # optional; presentational hint
|
|
184
|
+
spec.metadata["rails_hyperdrive_targets"] = "sidekiq"
|
|
213
185
|
```
|
|
214
186
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
`rails_hyperdrive_targets` is a coarse pre-install hint — it is never reconciled against the frontmatter `gem:`; once the gem is bundled, the frontmatter alone governs what installs.
|
|
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).
|
|
218
188
|
|
|
219
189
|
---
|
|
220
190
|
|
|
221
|
-
##
|
|
222
|
-
|
|
223
|
-
Rails Hyperdrive is **dev-only**. The engine refuses to handle requests outside `Rails.env.development?` and enforces an origin allowlist (`localhost`, `127.0.0.1`, `[::1]`). See [SECURITY.md](SECURITY.md).
|
|
191
|
+
## Requirements
|
|
224
192
|
|
|
225
|
-
|
|
193
|
+
Ruby ≥ 3.2, Rails ≥ 7.2. Tested against Rails 7.2 and 8.1 on Ruby 3.2–3.4.
|
|
226
194
|
|
|
227
195
|
## License
|
|
228
196
|
|
|
@@ -4,7 +4,7 @@ Description:
|
|
|
4
4
|
merging into any servers already configured there
|
|
5
5
|
- mounts Rails::Hyperdrive::Engine at /_hyperdrive in config/routes.rb (idempotent)
|
|
6
6
|
- discovers companion-gem skills + guidelines in the bundle and installs
|
|
7
|
-
them
|
|
7
|
+
them (skills to .claude/skills/, guidelines to
|
|
8
8
|
.claude/hyperdrive/guidelines/)
|
|
9
9
|
- maintains the .claude/hyperdrive/index.md aggregator and injects one
|
|
10
10
|
@-include line into CLAUDE.md — both only while a companion gem ships
|
|
@@ -13,7 +13,8 @@ Description:
|
|
|
13
13
|
|
|
14
14
|
Re-running re-syncs content and leaves locally-modified files untouched
|
|
15
15
|
(skip + warn). Routine content refresh is bin/rails hyperdrive:sync
|
|
16
|
-
(
|
|
16
|
+
(see its --merge, --sidecar, and --overwrite flags for reconciling
|
|
17
|
+
locally-modified files).
|
|
17
18
|
|
|
18
19
|
Examples:
|
|
19
20
|
bin/rails hyperdrive:init
|
|
@@ -2,20 +2,34 @@ Description:
|
|
|
2
2
|
Syncs Rails Hyperdrive content to match the current bundle (run after
|
|
3
3
|
`bundle update` or after adding a companion gem):
|
|
4
4
|
- discovers companion-gem skills + guidelines in the bundle and installs
|
|
5
|
-
them
|
|
5
|
+
them (skills to .claude/skills/, guidelines to
|
|
6
6
|
.claude/hyperdrive/guidelines/)
|
|
7
7
|
- maintains the .claude/hyperdrive/index.md aggregator and the single
|
|
8
8
|
@-include line in CLAUDE.md — both only while a companion gem ships a
|
|
9
9
|
guideline, and both removed when the last one goes
|
|
10
10
|
- tracks everything in .hyperdrive/lock.yml
|
|
11
11
|
|
|
12
|
-
Locally-modified files are left untouched (skip + warn).
|
|
13
|
-
|
|
12
|
+
Locally-modified files are left untouched by default (skip + warn). Three
|
|
13
|
+
mutually-exclusive strategies reconcile them with a new upstream:
|
|
14
|
+
--merge three-way-merge the upstream change into the live file
|
|
15
|
+
(clean merges only; anything else falls back to --sidecar,
|
|
16
|
+
so no conflict marker ever lands in a live file)
|
|
17
|
+
--sidecar write the new upstream body to <file>.new next to the live
|
|
18
|
+
file, leaving the live file untouched
|
|
19
|
+
--overwrite restore the live file to the gem-shipped content,
|
|
20
|
+
discarding local edits
|
|
21
|
+
|
|
22
|
+
Resolving a sidecar: fold what you want from <file>.new into the live
|
|
23
|
+
file, then delete <file>.new — or `mv <file>.new <file>` to accept the
|
|
24
|
+
upstream wholesale. Either way the next sync is quiet: the lock already
|
|
25
|
+
records the delivered upstream, so the same version is never re-offered.
|
|
14
26
|
|
|
15
27
|
Touches no bootstrap artifact: .mcp.json, the engine mount, the optional
|
|
16
28
|
initializer, and .gitignore belong to hyperdrive:init and are left alone.
|
|
17
29
|
|
|
18
30
|
Examples:
|
|
19
31
|
bin/rails hyperdrive:sync
|
|
32
|
+
bin/rails hyperdrive:sync --merge
|
|
33
|
+
bin/rails hyperdrive:sync --sidecar
|
|
20
34
|
bin/rails hyperdrive:sync --overwrite
|
|
21
35
|
bin/rails hyperdrive:sync --dry-run
|
|
@@ -15,8 +15,17 @@ module Rails
|
|
|
15
15
|
source_root File.expand_path("templates", __dir__)
|
|
16
16
|
|
|
17
17
|
class_option :overwrite, type: :boolean, default: false, desc: "Restore locally-modified managed files to the gem-shipped content."
|
|
18
|
+
class_option :merge, type: :boolean, default: false, desc: "Three-way-merge upstream changes into locally-modified files; falls back to sidecar delivery."
|
|
19
|
+
class_option :sidecar, type: :boolean, default: false, desc: "Deliver upstream changes for locally-modified files to <file>.new sidecars."
|
|
18
20
|
class_option :dry_run, type: :boolean, default: false, desc: "Show what would change; write nothing."
|
|
19
21
|
|
|
22
|
+
def verify_options
|
|
23
|
+
chosen = %i[overwrite merge sidecar].select { |flag| options[flag] }
|
|
24
|
+
return if chosen.size <= 1
|
|
25
|
+
raise Thor::Error,
|
|
26
|
+
"hyperdrive: #{chosen.map { |flag| "--#{flag}" }.join(" and ")} are mutually exclusive; pick one"
|
|
27
|
+
end
|
|
28
|
+
|
|
20
29
|
def verify_environment
|
|
21
30
|
runner.verify_environment!
|
|
22
31
|
end
|
|
@@ -26,7 +35,13 @@ module Rails
|
|
|
26
35
|
end
|
|
27
36
|
|
|
28
37
|
def sync_content
|
|
29
|
-
|
|
38
|
+
mode =
|
|
39
|
+
if options[:overwrite] then :overwrite
|
|
40
|
+
elsif options[:merge] then :merge
|
|
41
|
+
elsif options[:sidecar] then :sidecar
|
|
42
|
+
else :preserve
|
|
43
|
+
end
|
|
44
|
+
runner.install(mode: mode)
|
|
30
45
|
end
|
|
31
46
|
|
|
32
47
|
def print_summary
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require "bundler"
|
|
2
|
+
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
3
|
+
require "rails/hyperdrive/drift_verdict"
|
|
4
|
+
require "rails/hyperdrive/skill_template"
|
|
5
|
+
|
|
6
|
+
module Rails
|
|
7
|
+
module Hyperdrive
|
|
8
|
+
# Best-effort reconstruction of the install-ready body an artifact had at
|
|
9
|
+
# the gem version a lock entry records, read from installed gem
|
|
10
|
+
# directories. The result is sha-gated against the lock entry: anything
|
|
11
|
+
# that does not rebuild to exactly the recorded bytes counts as
|
|
12
|
+
# unavailable.
|
|
13
|
+
module AncestorLocator
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Never raises; any failure returns nil (ancestor unavailable).
|
|
17
|
+
def locate(kind:, relpath:, lock_entry:, final_name: nil, gem_paths: Gem.path, resolved: nil)
|
|
18
|
+
return nil unless lock_entry&.source_gem && lock_entry.source_version && lock_entry.source_sha
|
|
19
|
+
return nil if relpath.nil? || relpath.to_s.empty?
|
|
20
|
+
|
|
21
|
+
Array(gem_paths).each do |home|
|
|
22
|
+
gem_root = File.join(home.to_s, "gems", "#{lock_entry.source_gem}-#{lock_entry.source_version}")
|
|
23
|
+
body = read_candidate(gem_root, relpath.to_s, kind: kind, resolved: resolved)
|
|
24
|
+
next unless body
|
|
25
|
+
|
|
26
|
+
ready = install_ready(body, kind: kind, final_name: final_name)
|
|
27
|
+
return ready if DriftVerdict.body_sha(ready) == lock_entry.source_sha
|
|
28
|
+
rescue StandardError
|
|
29
|
+
next
|
|
30
|
+
end
|
|
31
|
+
nil
|
|
32
|
+
rescue StandardError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def read_candidate(gem_root, relpath, kind:, resolved:)
|
|
37
|
+
exact = File.join(gem_root, relpath)
|
|
38
|
+
if File.file?(exact)
|
|
39
|
+
return kind.to_s == "skill_support" ? File.binread(exact) : File.read(exact)
|
|
40
|
+
end
|
|
41
|
+
return nil unless relpath.end_with?(".md")
|
|
42
|
+
|
|
43
|
+
twin = "#{exact}.erb"
|
|
44
|
+
return nil unless File.file?(twin)
|
|
45
|
+
|
|
46
|
+
map = resolved || resolved_bundle
|
|
47
|
+
return nil unless map
|
|
48
|
+
SkillTemplate.render(File.read(twin), resolved: map)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def install_ready(body, kind:, final_name:)
|
|
52
|
+
case kind.to_s
|
|
53
|
+
when "skill"
|
|
54
|
+
ready = BundlerArtifactDiscovery.install_ready_body(
|
|
55
|
+
BundlerArtifactDiscovery::Artifact.new(artifact_type: :skill, body: body)
|
|
56
|
+
)
|
|
57
|
+
final_name ? ready.sub(/^name:\s*.+$/, "name: #{final_name}") : ready
|
|
58
|
+
when "guideline"
|
|
59
|
+
BundlerArtifactDiscovery.install_ready_body(
|
|
60
|
+
BundlerArtifactDiscovery::Artifact.new(artifact_type: :guideline, body: body)
|
|
61
|
+
)
|
|
62
|
+
else
|
|
63
|
+
body
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resolved_bundle
|
|
68
|
+
::Bundler.load.specs.to_a.each_with_object({}) { |s, h| h[s.name.to_s] = s.version }
|
|
69
|
+
rescue StandardError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private_class_method :read_candidate, :install_ready, :resolved_bundle
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -15,7 +15,7 @@ module Rails
|
|
|
15
15
|
case state
|
|
16
16
|
when :missing then "#{path} (from #{bundle_source})"
|
|
17
17
|
when :outdated then "#{path} (#{locked_source} → #{bundle_source})"
|
|
18
|
-
when :orphaned then "#{path} (
|
|
18
|
+
when :orphaned then "#{path} (no longer shipped by #{locked_source})"
|
|
19
19
|
else path
|
|
20
20
|
end
|
|
21
21
|
end
|
|
@@ -6,10 +6,12 @@ module Rails
|
|
|
6
6
|
module Hyperdrive
|
|
7
7
|
module BundlerArtifactDiscovery
|
|
8
8
|
SKILL_FILE_NAMES = ["SKILL.md", "SKILL.md.erb"].freeze
|
|
9
|
+
INSTALLER_KEY = /\A(?:gem|versions|conditional):/.freeze
|
|
9
10
|
|
|
10
11
|
Artifact = Struct.new(
|
|
11
12
|
:name, :description, :target_gem, :versions, :artifact_type,
|
|
12
13
|
:source_gem, :path, :body, :spec_version, :support_files,
|
|
14
|
+
:source_root,
|
|
13
15
|
keyword_init: true
|
|
14
16
|
) do
|
|
15
17
|
def skill?
|
|
@@ -133,6 +135,7 @@ module Rails
|
|
|
133
135
|
path: path,
|
|
134
136
|
body: body,
|
|
135
137
|
spec_version: source_spec.version.to_s,
|
|
138
|
+
source_root: source_spec.full_gem_path.to_s,
|
|
136
139
|
support_files:
|
|
137
140
|
if type == :skill
|
|
138
141
|
conditioned_support_files(
|
|
@@ -254,12 +257,35 @@ module Rails
|
|
|
254
257
|
end
|
|
255
258
|
|
|
256
259
|
def install_ready_body(artifact)
|
|
257
|
-
return artifact.body if artifact.skill?
|
|
260
|
+
return strip_installer_keys(artifact.body) if artifact.skill?
|
|
258
261
|
|
|
259
262
|
_frontmatter, rest = split_frontmatter(artifact.body)
|
|
260
263
|
(rest || artifact.body).sub(/\A\n+/, "")
|
|
261
264
|
end
|
|
262
265
|
|
|
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
|
+
|
|
263
289
|
def split_frontmatter(body)
|
|
264
290
|
lines = body.lines
|
|
265
291
|
return [nil, body] unless lines.first&.strip == "---"
|
|
@@ -313,6 +339,7 @@ module Rails
|
|
|
313
339
|
:conditioned_support_files, :apply_conditional_filter,
|
|
314
340
|
:conditional_satisfied?, :malformed_requirements?,
|
|
315
341
|
:render_support_templates, :erb_template?,
|
|
342
|
+
:strip_installer_keys,
|
|
316
343
|
:split_frontmatter, :parse_targets, :match_targets,
|
|
317
344
|
:version_satisfied?, :no_match_reason, :safe_bundler_specs
|
|
318
345
|
end
|