rails-hyperdrive 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -1
- data/README.md +81 -21
- data/lib/generators/hyperdrive/install/USAGE +4 -2
- data/lib/generators/hyperdrive/install/install_generator.rb +47 -1
- data/lib/generators/hyperdrive/install_summary.rb +1 -1
- data/lib/generators/hyperdrive/sync/USAGE +15 -3
- data/lib/generators/hyperdrive/sync/sync_generator.rb +11 -1
- data/lib/generators/hyperdrive/sync_runner.rb +63 -4
- data/lib/rails/hyperdrive/ancestor_locator.rb +36 -1
- data/lib/rails/hyperdrive/artifact_status.rb +13 -5
- data/lib/rails/hyperdrive/auto_install.rb +17 -8
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +1 -1
- data/lib/rails/hyperdrive/config_file.rb +135 -0
- data/lib/rails/hyperdrive/install_layout.rb +1 -0
- data/lib/rails/hyperdrive/install_pipeline.rb +129 -38
- data/lib/rails/hyperdrive/install_plan.rb +6 -6
- data/lib/rails/hyperdrive/lock_file.rb +72 -50
- data/lib/rails/hyperdrive/resolve/prompt.md.erb +47 -0
- data/lib/rails/hyperdrive/resolve_prompt.rb +42 -0
- data/lib/rails/hyperdrive/sidecar_resolver.rb +215 -0
- data/lib/rails/hyperdrive/stack_profile.rb +3 -3
- data/lib/rails/hyperdrive/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b2a834f37a237a640a01631dc04ab991791e87535b90cb5fc7e44f69e24dccd0
|
|
4
|
+
data.tar.gz: 03dd055200f9407f86719da8a221207b1b0a5987e12657284399ce5d8fae2840
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cc5dcb6940b29b6bf182e704f9419d336616fe999dc214325697d3b6a9aa4673fca9b003ba4a621eb348ea0be34dc892f648a1e6882add7cf261b7710a29d597
|
|
7
|
+
data.tar.gz: b810742750f5bd8a747a1c27fee799d80164bb306ad69ad4ff0445a8a6deefdde1171922058ee325d55b8df2a1b187a2e0e3fb2ab2ec41a204ccbce50bdca228
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-09-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **A pending sidecar now remembers what your edits were based on.** While
|
|
15
|
+
`<file>.new` is waiting, `.hyperdrive/lock.yml` also records the upstream the
|
|
16
|
+
live file was edited on top of. `hyperdrive:sync --merge` uses it to attempt a
|
|
17
|
+
real three-way merge over a file that already has a sidecar — including one
|
|
18
|
+
where the same upstream is still on offer — instead of refusing outright, and
|
|
19
|
+
`--resolve` can hand its tool a `$BASE` (and a `$PREVIOUS_SOURCE`) on every
|
|
20
|
+
retry, not just on the run that wrote the sidecar. The keys are written on
|
|
21
|
+
delivery, kept put when a newer upstream refreshes the sidecar, and dropped by
|
|
22
|
+
the first run that finds the sidecar gone; deleting `<file>.new` is still the
|
|
23
|
+
only thing that marks a delivery resolved.
|
|
24
|
+
|
|
25
|
+
- **`hyperdrive:sync --resolve`, a `git mergetool` for sidecars.** It delivers
|
|
26
|
+
to sidecars on its own (or after `--merge` has taken what git can), hands every
|
|
27
|
+
unresolved `<file>.new` to the command you name in `resolve.command` in
|
|
28
|
+
`.hyperdrive/config.yml`, and deletes the sidecar when that command exits 0; any other exit leaves the live file, the
|
|
29
|
+
sidecar, and the lockfile untouched with the reason printed. No command ships
|
|
30
|
+
with the gem, and `--resolve` without one configured stops before syncing
|
|
31
|
+
anything. The command receives `$LOCAL`, `$REMOTE`, `$BASE`, `$MERGED`,
|
|
32
|
+
`$SOURCE`, `$PREVIOUS_SOURCE`, `$KIND`, and `$PROMPT` — substituted per
|
|
33
|
+
argument and exported as `HYPERDRIVE_*` variables — where `$PROMPT` is a
|
|
34
|
+
tool-neutral set of reconciliation instructions you can replace with your own
|
|
35
|
+
ERB template via `resolve.prompt:`. A sidecar you edited yourself is never
|
|
36
|
+
handed off, `--dry-run` runs nothing, and the resolver never runs during
|
|
37
|
+
`bundle install`.
|
|
38
|
+
|
|
39
|
+
- **`.hyperdrive/config.yml`, a settings file that belongs to you.** The
|
|
40
|
+
hand-edited `disabled:` (per kind) and `enabled:` (companion opt-in) lists now
|
|
41
|
+
live there instead of in `.hyperdrive/lock.yml`. `hyperdrive:init` creates the
|
|
42
|
+
file with empty sections and an explanatory header if it is not already there,
|
|
43
|
+
and no hyperdrive command ever writes to it again — the relationship is
|
|
44
|
+
`Gemfile` to `Gemfile.lock`: the config holds your choices, the lock holds
|
|
45
|
+
state. Reads are fail-open: a missing file is an empty config, and anything
|
|
46
|
+
malformed warns and is ignored rather than failing a run. A gitignored config
|
|
47
|
+
now draws the same warning a gitignored lock does.
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- **`hyperdrive:sync --merge` now says what a clean merge is.** When a run
|
|
52
|
+
merges anything, the summary carries one footer line: a clean three-way merge
|
|
53
|
+
is textually non-overlapping, not verified, and `git diff` is where you check
|
|
54
|
+
what it produced. Nothing about merging itself changed; a run that merged
|
|
55
|
+
nothing prints no footer.
|
|
56
|
+
- **BREAKING — `disabled:`/`enabled:` in `.hyperdrive/lock.yml` are no longer
|
|
57
|
+
read, and there is no migration.** `init`, `sync`, and `bundle install` print
|
|
58
|
+
one warning naming the lock keys, and the next lock write drops them. Re-declare
|
|
59
|
+
those settings in `.hyperdrive/config.yml` first: otherwise every artifact you
|
|
60
|
+
had disabled installs again, and a gem that was opted in only through the
|
|
61
|
+
lock's `enabled:` list is no longer a companion, so the next `init` or `sync`
|
|
62
|
+
removes its unedited artifacts as stale (edited ones are warned about and
|
|
63
|
+
left).
|
|
64
|
+
- The lock schema version is now **3**. A 0.8.0 installer halts on a schema-3
|
|
65
|
+
lock instead of reading the absent `disabled:` list as empty and reinstalling
|
|
66
|
+
every artifact you opted out of; installers before 0.8.0 ship no schema guard
|
|
67
|
+
and do reinstall them.
|
|
68
|
+
|
|
10
69
|
## [0.8.0] - 2026-08-26
|
|
11
70
|
|
|
12
71
|
### Added
|
|
@@ -754,7 +813,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
754
813
|
- `hyperdrive:init` generator that installs architecture skills and auto-discovers
|
|
755
814
|
per-gem skills.
|
|
756
815
|
|
|
757
|
-
[Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.
|
|
816
|
+
[Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.9.0...HEAD
|
|
817
|
+
[0.9.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.9.0
|
|
758
818
|
[0.8.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.8.0
|
|
759
819
|
[0.7.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.7.0
|
|
760
820
|
[0.6.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.6.0
|
data/README.md
CHANGED
|
@@ -38,7 +38,10 @@ $ bundle add rails-hyperdrive-sidekiq --group=development
|
|
|
38
38
|
$ bin/rails hyperdrive:init
|
|
39
39
|
|
|
40
40
|
create .mcp.json
|
|
41
|
+
append .gitignore
|
|
42
|
+
append Gemfile
|
|
41
43
|
insert config/routes.rb
|
|
44
|
+
create .hyperdrive/config.yml
|
|
42
45
|
create .claude/hyperdrive/guidelines/jobs-sidekiq.md
|
|
43
46
|
create .claude/skills/sidekiq-idempotency/SKILL.md
|
|
44
47
|
create .claude/hyperdrive/index.md
|
|
@@ -49,12 +52,17 @@ $ bin/rails hyperdrive:init
|
|
|
49
52
|
done hyperdrive initialized
|
|
50
53
|
Mount: /_hyperdrive (in config/routes.rb)
|
|
51
54
|
Server: 8 MCP tools at http://localhost:3000/_hyperdrive/mcp
|
|
52
|
-
Installed 1 skill, 1 guideline
|
|
55
|
+
Installed 1 skill, 1 guideline, 0 agents, 0 commands
|
|
53
56
|
|
|
54
57
|
rails-hyperdrive-sidekiq@1.2.0
|
|
55
58
|
skill sidekiq-idempotency
|
|
56
59
|
guideline jobs-sidekiq
|
|
57
60
|
|
|
61
|
+
Next steps:
|
|
62
|
+
1. bin/rails server
|
|
63
|
+
2. Open Claude Code in this directory; it will read .mcp.json
|
|
64
|
+
3. Verify the connection: curl -s http://localhost:3000/_hyperdrive/mcp ...
|
|
65
|
+
|
|
58
66
|
# 4. Start the dev server
|
|
59
67
|
$ bin/dev
|
|
60
68
|
|
|
@@ -63,7 +71,7 @@ $ bin/dev
|
|
|
63
71
|
# → agent has 8 tools, the eager guidelines (via CLAUDE.md), and the lazy skills
|
|
64
72
|
```
|
|
65
73
|
|
|
66
|
-
The generated `.mcp.json` points at `http://localhost:3000<mount>/mcp
|
|
74
|
+
The engine mounts at `/_hyperdrive` by default; `--mount-at /some/path` moves both the route and the URL written to `.mcp.json`. The generated `.mcp.json` points at `http://localhost:3000<mount>/mcp`, so if your dev server runs on another port, edit the URL there.
|
|
67
75
|
|
|
68
76
|
---
|
|
69
77
|
|
|
@@ -74,12 +82,12 @@ The generated `.mcp.json` points at `http://localhost:3000<mount>/mcp`. If your
|
|
|
74
82
|
| # | Tool | Purpose |
|
|
75
83
|
|---|------|---------|
|
|
76
84
|
| 1 | `run_ruby` | Eval Ruby in the booted Rails process, with timeout + output capture |
|
|
77
|
-
| 2 | `run_sql` | Read-only SQL via the AR connection (
|
|
85
|
+
| 2 | `run_sql` | Read-only SQL via the AR connection (`SELECT`/`WITH`/`EXPLAIN`/`SHOW`/`PRAGMA` only; 100 rows max) |
|
|
78
86
|
| 3 | `tail_logs` | Tail the last N lines of a log under `log/` (defaults to `log/<env>.log`) |
|
|
79
87
|
| 4 | `list_models` | List Active Record model classes with columns/validations/associations |
|
|
80
88
|
| 5 | `locate_source` | Resolve `Const` / `Const#method` / `Const.method` / `dep:<gem>` to a file:line |
|
|
81
89
|
| 6 | `lookup_doc` | Look up RDoc for a symbol (via `ri`) |
|
|
82
|
-
| 7 | `describe_app` | Snapshot: Rails/Ruby/DB versions
|
|
90
|
+
| 7 | `describe_app` | Snapshot: Rails/Ruby/DB versions, direct gem dependencies, installed skills |
|
|
83
91
|
| 8 | `list_routes` | All routes: HTTP verb, path, controller#action, named route |
|
|
84
92
|
|
|
85
93
|
Plus two MCP resources: `hyperdrive://stack-profile` (JSON snapshot of your resolved stack) and `hyperdrive://skills/{name}` (the markdown body of each installed skill). The skill list is enumerated at server boot and the stack snapshot is memoized per process, so a newly installed skill or a changed bundle reaches these two only after a dev-server restart.
|
|
@@ -95,26 +103,73 @@ Companion gems ship four artifact kinds, tuned for how agents consume context:
|
|
|
95
103
|
|
|
96
104
|
A companion gem declares in its manifest which gem each artifact targets and at which versions, so what lands in your app is what matches your `Gemfile.lock`, and nothing aimed at a library or version you don't run.
|
|
97
105
|
|
|
98
|
-
With no companion gems, `hyperdrive:init` sets up just the
|
|
106
|
+
With no companion gems, `hyperdrive:init` sets up just the plumbing (`.mcp.json`, the engine mount, the bundler plugin line in your `Gemfile`, a `.gitignore` rule for the discover cache, an empty `.hyperdrive/config.yml`, and the lockfile) and puts **nothing** into your agent's context window. The two halves are independently skippable: `--skip-mcp` writes no `.mcp.json` entry and no mount; `--skip-content` skips the content, the config file, and the lockfile and leaves the rest. Neither removes anything already in place.
|
|
99
107
|
|
|
100
108
|
---
|
|
101
109
|
|
|
102
110
|
## Staying in sync
|
|
103
111
|
|
|
104
|
-
**After `bundle install`: automatic.** `hyperdrive:init` registers the [`bundler-hyperdrive`](bundler-hyperdrive/) Bundler plugin in your Gemfile. From then on, adding a companion gem lands its artifacts on that very `bundle install`, with no extra command to run. 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`.
|
|
112
|
+
**After `bundle install`: automatic.** `hyperdrive:init` registers the [`bundler-hyperdrive`](bundler-hyperdrive/) Bundler plugin in your Gemfile. From then on, adding a companion gem lands its artifacts on that very `bundle install`, with no extra command to run. 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`. It runs only where an install is safe to touch: a development environment (`RAILS_ENV`/`RACK_ENV` unset or `development`), no `CI` variable, no frozen bundle, and an app that already has a `.hyperdrive/lock.yml` (it tops up an initialized app, never bootstraps one). Because it never edits `CLAUDE.md`, a guideline that first arrives this way is not in context until the next `hyperdrive:sync` wires it in; the plugin says so when that happens.
|
|
113
|
+
|
|
114
|
+
**`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 with no flags it leaves locally modified files untouched (skip + warn). When you *have* edited an installed file and its gem ships a new version, one flag picks the strategy:
|
|
105
115
|
|
|
106
|
-
|
|
116
|
+
| Flag | What happens to the live file | What happens to your edits | Falls back to |
|
|
117
|
+
|---|---|---|---|
|
|
118
|
+
| *(none)* | Untouched, with a warning naming the flags below | Kept | — |
|
|
119
|
+
| `--merge` | Rewritten with a git three-way merge (base = the gem version you last installed, ours = your file, theirs = the new upstream) when it applies cleanly | Kept | `--sidecar`, whenever git cannot produce a clean result |
|
|
120
|
+
| `--sidecar` | Untouched; the new upstream body is written next to it as `<file>.new` | Kept, byte-for-byte | — |
|
|
121
|
+
| `--overwrite` | Restored to the gem-shipped content | Discarded | — |
|
|
122
|
+
| `--resolve` | Delivers as `--sidecar` (or as `--merge`, when both are given), then hands each unresolved `<file>.new` to a command you configure; exit 0 deletes the sidecar | Up to your command | — |
|
|
107
123
|
|
|
108
|
-
|
|
109
|
-
|---|---|---|
|
|
110
|
-
| `--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, so nothing half-merged ever goes live |
|
|
111
|
-
| `--sidecar` | Untouched; the new upstream body is written next to it as `<file>.new` | Kept, byte-for-byte |
|
|
112
|
-
| `--overwrite` | Restored to the gem-shipped content | Discarded |
|
|
124
|
+
`--merge`, `--sidecar`, and `--overwrite` are mutually exclusive. `--resolve` stacks on top: alone it means "sidecar, then resolve", `--merge --resolve` means "git merges what it can, your command takes the rest", and `--overwrite --resolve` is rejected because an overwrite leaves nothing to resolve. Every combination accepts `--dry-run`, which prints the plan and writes nothing.
|
|
113
125
|
|
|
114
|
-
|
|
126
|
+
The fallback from `--merge` to a sidecar is what keeps a half-merged file from ever going live. It happens, with the reason on the status line, when the merge would need conflict markers; when the gem version you last installed is no longer on disk (CI, after `gem cleanup`), so there is no ancestor to merge against; when the file has no earlier install recorded in the lockfile; when the content is binary; or when `git` is not on your PATH. A clean merge is textually non-overlapping, not semantically correct: git merges two contradictory edits cleanly when unchanged lines sit between them, so `git diff` after a `--merge` run is a review step, not a formality.
|
|
127
|
+
|
|
128
|
+
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). A file that already has a sidecar is not out of reach: while one is pending the lockfile also records the version your edits were based on, so a later `--merge` still has a proper base to give git — including for the delivery already waiting in the `.new`.
|
|
115
129
|
|
|
116
130
|
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.
|
|
117
131
|
|
|
132
|
+
**`--resolve`: hand the sidecars to a tool.** `--resolve` automates that last step, `git mergetool` style. After delivery it hands every unresolved `<file>.new` — from this run or left over from an earlier one — to the command named in `.hyperdrive/config.yml`, and deletes the sidecar when that command exits 0. The gem ships no command of its own, so nothing runs that you did not name:
|
|
133
|
+
|
|
134
|
+
```yaml
|
|
135
|
+
# .hyperdrive/config.yml
|
|
136
|
+
resolve:
|
|
137
|
+
command: claude -p --permission-mode acceptEdits $PROMPT
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
bin/rails hyperdrive:sync --resolve # every edited file goes to your tool
|
|
142
|
+
bin/rails hyperdrive:sync --merge --resolve # git merges what it can, your tool takes the rest
|
|
143
|
+
git diff # then you review
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The command is split with shell word rules and run with no shell, from the app root. Each argument gets these placeholders substituted, and every one is also exported as an environment variable (`$LOCAL` → `HYPERDRIVE_LOCAL`, and so on), so a wrapper script needs no argument parsing:
|
|
147
|
+
|
|
148
|
+
| Placeholder | Value |
|
|
149
|
+
|---|---|
|
|
150
|
+
| `$LOCAL` | The live file, with your edits |
|
|
151
|
+
| `$REMOTE` | The sidecar: the new upstream body |
|
|
152
|
+
| `$BASE` | The common ancestor — available whenever the lock records the pending delivery's ancestor and that gem version is still on disk; otherwise the argument is dropped |
|
|
153
|
+
| `$MERGED` | The live file again: the file your command must write |
|
|
154
|
+
| `$SOURCE` | `<gem>@<version>` of the new upstream |
|
|
155
|
+
| `$PREVIOUS_SOURCE` | `<gem>@<version>` your copy is based on — `$BASE`'s source — when the lockfile records one |
|
|
156
|
+
| `$KIND` | `skill`, `guideline`, `agent`, `command`, or `skill_support` |
|
|
157
|
+
| `$PROMPT` | Ready-made instructions for an agent, naming the paths above |
|
|
158
|
+
|
|
159
|
+
`$BASE` is a temporary file outside the app, removed as soon as the command returns, so a tool that is confined to the project directory needs it granted explicitly:
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
#!/usr/bin/env bash
|
|
163
|
+
# bin/hyperdrive-resolve, used as `command: bin/hyperdrive-resolve`
|
|
164
|
+
args=(-p --permission-mode acceptEdits)
|
|
165
|
+
[ -n "$HYPERDRIVE_BASE" ] && args+=(--add-dir "$(dirname "$HYPERDRIVE_BASE")")
|
|
166
|
+
exec claude "${args[@]}" "$HYPERDRIVE_PROMPT"
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Replace the shipped prompt with your own ERB template — same placeholders, as lower-case locals (`local`, `remote`, `base`, `merged`, `source`, `previous_source`, `kind`) — with `resolve.prompt: .hyperdrive/resolve-prompt.md.erb`.
|
|
170
|
+
|
|
171
|
+
Exit 0 is a promise: the sidecar is deleted on the strength of it, whether or not `$MERGED` was written. Any other exit — including a command that is not on your PATH — leaves the live file, the sidecar, and the lockfile exactly as they were, with the reason printed, so a failed resolve is just an ordinary unresolved sidecar. `--resolve` runs only on an explicit `hyperdrive:sync`, never during `bundle install`. `--dry-run` prints what it would hand off and runs nothing. A sidecar you edited yourself is never handed to the command.
|
|
172
|
+
|
|
118
173
|
**`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. It is read-only, caches results for 24h (`--refresh` re-queries), and never touches your Gemfile or makes network calls unless you invoke it.
|
|
119
174
|
|
|
120
175
|
---
|
|
@@ -133,20 +188,23 @@ CLAUDE.md # user-owned; ONE injected line: @.claude
|
|
|
133
188
|
<supporting files> # optional extras (references/, examples/, …), installed as shipped (*.md.erb rendered)
|
|
134
189
|
.claude/agents/<name>.md # companion-shipped subagent, installed verbatim
|
|
135
190
|
.claude/commands/<name>.md # companion-shipped slash command, installed verbatim
|
|
191
|
+
.hyperdrive/config.yml # yours to edit: disabled:/enabled:/resolve: settings
|
|
136
192
|
.hyperdrive/lock.yml # git-tracked manifest (source gem, version, content hash)
|
|
137
193
|
```
|
|
138
194
|
|
|
139
|
-
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`
|
|
195
|
+
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` and `hyperdrive:sync` warn 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`. The lockfile carries a schema version, and a rails-hyperdrive older than the one that wrote it stops with an upgrade message instead of rewriting state it cannot read.
|
|
196
|
+
|
|
197
|
+
`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). A `CLAUDE.md` you had before `hyperdrive:init` gets the one line appended, and tear-down strips just that line; a `CLAUDE.md` the installer created is deleted only while it is still byte-identical to what was written.
|
|
140
198
|
|
|
141
|
-
|
|
199
|
+
Both links are yours to cut. Delete the `@.claude/hyperdrive/index.md` line from `CLAUDE.md` and no later run re-adds it (the lockfile remembers the choice, and the next run says so once). Delete a single `@guidelines/<name>.md` line from `index.md` and that guideline leaves eager context while staying installed; it is not re-added either, and an `index.md` you have emptied this way is kept because it is the record of those choices.
|
|
142
200
|
|
|
143
201
|
### Your edits win
|
|
144
202
|
|
|
145
|
-
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.
|
|
203
|
+
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; `--merge` and `--sidecar` ([above](#staying-in-sync)) bring the upstream change in without losing yours.
|
|
146
204
|
|
|
147
205
|
### Turning off a single artifact
|
|
148
206
|
|
|
149
|
-
A companion gem you want for one skill but not another doesn't have to be all-or-nothing. Add the artifact's name to the `disabled:` list in `.hyperdrive/
|
|
207
|
+
A companion gem you want for one skill but not another doesn't have to be all-or-nothing. Add the artifact's name to the `disabled:` list in `.hyperdrive/config.yml` — `hyperdrive:init` creates that file with empty sections, so the shape is already there:
|
|
150
208
|
|
|
151
209
|
```yaml
|
|
152
210
|
disabled:
|
|
@@ -162,26 +220,28 @@ disabled:
|
|
|
162
220
|
|
|
163
221
|
A disabled artifact is never installed, and one already on disk is removed on the next `hyperdrive:init` or `hyperdrive:sync`, but **only if you haven't edited it**. A locally modified file is reported and left alone, for you to delete when you're ready. Disabling a skill removes its shipped supporting files under the same per-file rule; files you created yourself in the skill directory survive and keep the directory alive. Disabling a guideline also drops its line from `index.md`, so it leaves eager context along with the file.
|
|
164
222
|
|
|
165
|
-
The list is yours to edit; the generator only reads it
|
|
223
|
+
The list is yours to edit; the generator only reads it. 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.
|
|
224
|
+
|
|
225
|
+
Earlier releases kept `disabled:` and `enabled:` in `.hyperdrive/lock.yml`. They are no longer read from there: a lock still carrying them draws one warning and the next write drops them. Move the lists into `.hyperdrive/config.yml` first: otherwise every artifact you had disabled installs again, and a gem that was enabled only there stops counting as a companion, so the next `init` or `sync` removes its unedited artifacts as stale.
|
|
166
226
|
|
|
167
227
|
To skip installed content wholesale instead, pass `--skip-content` to `hyperdrive:init`.
|
|
168
228
|
|
|
169
229
|
### Opting into a gem's bundled skills
|
|
170
230
|
|
|
171
|
-
Ordinary gems (not built as hyperdrive companions) sometimes ship a top-level `skills/` directory of 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/
|
|
231
|
+
Ordinary gems (not built as hyperdrive companions) sometimes ship a top-level `skills/` directory of 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/config.yml` and re-run `hyperdrive:sync`:
|
|
172
232
|
|
|
173
233
|
```yaml
|
|
174
234
|
enabled:
|
|
175
235
|
- foo
|
|
176
236
|
```
|
|
177
237
|
|
|
178
|
-
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
|
|
238
|
+
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:`; `.hyperdrive/config.yml` is yours alone, and no hyperdrive command writes to it after `hyperdrive:init` creates it.
|
|
179
239
|
|
|
180
240
|
---
|
|
181
241
|
|
|
182
242
|
## Safety
|
|
183
243
|
|
|
184
|
-
Rails Hyperdrive is **dev-only**, enforced in depth: the engine refuses requests outside `Rails.env.development?`, applies 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).
|
|
244
|
+
Rails Hyperdrive is **dev-only**, enforced in depth: the engine refuses requests outside `Rails.env.development?`, applies an origin allowlist (`localhost`, `127.0.0.1`, `[::1]`), and every tool re-checks the dev guard on invocation. The route line `hyperdrive:init` writes is itself guarded by `Rails.env.development?`, and an engine that loads outside development (the gem in the wrong Gemfile group, say) only logs a warning at boot. `run_sql` accepts read-only statements and refuses anything else. See [SECURITY.md](SECURITY.md).
|
|
185
245
|
|
|
186
246
|
---
|
|
187
247
|
|
|
@@ -13,6 +13,8 @@ Description:
|
|
|
13
13
|
- maintains the .claude/hyperdrive/index.md aggregator and injects one
|
|
14
14
|
@-include line into CLAUDE.md — both only while a companion gem ships
|
|
15
15
|
a guideline, and both removed when the last one goes
|
|
16
|
+
- creates .hyperdrive/config.yml (your disabled:/enabled:/resolve: settings) if
|
|
17
|
+
it is not there yet, and never touches it again
|
|
16
18
|
- tracks everything in .hyperdrive/lock.yml
|
|
17
19
|
|
|
18
20
|
The .mcp.json entry and the engine mount are skippable with --skip-mcp;
|
|
@@ -20,8 +22,8 @@ Description:
|
|
|
20
22
|
|
|
21
23
|
Re-running re-syncs content and leaves locally-modified files untouched
|
|
22
24
|
(skip + warn). Routine content refresh is bin/rails hyperdrive:sync
|
|
23
|
-
(see its --merge, --sidecar, and --
|
|
24
|
-
locally-modified files).
|
|
25
|
+
(see its --merge, --sidecar, --overwrite, and --resolve flags for
|
|
26
|
+
reconciling locally-modified files).
|
|
25
27
|
|
|
26
28
|
Examples:
|
|
27
29
|
bin/rails hyperdrive:init
|
|
@@ -2,6 +2,7 @@ require "rails/generators"
|
|
|
2
2
|
require "rails/generators/base"
|
|
3
3
|
require "json"
|
|
4
4
|
require "rails/hyperdrive/companion_discovery"
|
|
5
|
+
require "rails/hyperdrive/install_layout"
|
|
5
6
|
require "rails/hyperdrive/mcp_server"
|
|
6
7
|
require "generators/hyperdrive/content_sync_support"
|
|
7
8
|
require "generators/hyperdrive/gitignore_support"
|
|
@@ -22,12 +23,44 @@ module Rails
|
|
|
22
23
|
GEMFILE = "Gemfile".freeze
|
|
23
24
|
BUNDLER_PLUGIN = "bundler-hyperdrive".freeze
|
|
24
25
|
|
|
26
|
+
CONFIG_TEMPLATE = <<~YAML.freeze
|
|
27
|
+
# rails-hyperdrive settings. This file is yours: hyperdrive:init creates it
|
|
28
|
+
# once, and no hyperdrive command writes to it afterwards.
|
|
29
|
+
#
|
|
30
|
+
# disabled: artifacts never to install, by name, per kind. Naming a
|
|
31
|
+
# collision-postfixed variant (foo--gem_a) opts out that one source only.
|
|
32
|
+
# A disabled artifact already on disk is removed on the next init/sync
|
|
33
|
+
# unless you edited it.
|
|
34
|
+
# enabled: gems to scan as hyperdrive companions even though they do not
|
|
35
|
+
# opt in themselves; disabled: still wins per artifact.
|
|
36
|
+
# resolve: the command `hyperdrive:sync --resolve` hands each unresolved
|
|
37
|
+
# <file>.new sidecar to, git-mergetool style. Placeholders $LOCAL (the
|
|
38
|
+
# live file), $REMOTE (the sidecar), $BASE (the common ancestor, when
|
|
39
|
+
# one is available), $MERGED (the live file again — what the command
|
|
40
|
+
# must write), $SOURCE, $PREVIOUS_SOURCE, $KIND, and $PROMPT (the
|
|
41
|
+
# instructions to give an agent) are substituted per argument, and each
|
|
42
|
+
# is also exported as HYPERDRIVE_LOCAL, HYPERDRIVE_REMOTE, and so on.
|
|
43
|
+
# Exit 0 means resolved and the sidecar is deleted; any other exit
|
|
44
|
+
# leaves the file, the sidecar, and the lock untouched. prompt: names
|
|
45
|
+
# your own ERB template, relative to the app root, replacing the
|
|
46
|
+
# built-in one.
|
|
47
|
+
disabled:
|
|
48
|
+
skills: []
|
|
49
|
+
guidelines: []
|
|
50
|
+
agents: []
|
|
51
|
+
commands: []
|
|
52
|
+
enabled: []
|
|
53
|
+
# resolve:
|
|
54
|
+
# command: <your tool> $PROMPT
|
|
55
|
+
# prompt: .hyperdrive/resolve-prompt.md.erb
|
|
56
|
+
YAML
|
|
57
|
+
|
|
25
58
|
# No templates are rendered; source_root exists so Rails resolves the
|
|
26
59
|
# sibling USAGE file for `--help`.
|
|
27
60
|
source_root File.expand_path("templates", __dir__)
|
|
28
61
|
|
|
29
62
|
class_option :mount_at, type: :string, default: DEFAULT_MOUNT_AT, desc: "Engine mount path."
|
|
30
|
-
class_option :skip_content, type: :boolean, default: false, desc: "Skip all .claude content, CLAUDE.md, and the lockfile; leave the .mcp.json, .gitignore, Gemfile, and mount steps."
|
|
63
|
+
class_option :skip_content, type: :boolean, default: false, desc: "Skip all .claude content, CLAUDE.md, the config file, and the lockfile; leave the .mcp.json, .gitignore, Gemfile, and mount steps."
|
|
31
64
|
class_option :skip_mcp, type: :boolean, default: false, desc: "Skip MCP setup entirely; write no .mcp.json entry and no engine mount."
|
|
32
65
|
class_option :dry_run, type: :boolean, default: false, desc: "Show what would change; write nothing."
|
|
33
66
|
|
|
@@ -101,6 +134,19 @@ module Rails
|
|
|
101
134
|
inject_into_file routes_file, snippet, after: /Rails\.application\.routes\.draw do\s*\n/
|
|
102
135
|
end
|
|
103
136
|
|
|
137
|
+
# The settings are the user's, so an existing file is never rewritten or
|
|
138
|
+
# reformatted.
|
|
139
|
+
def bootstrap_config
|
|
140
|
+
return if options[:skip_content]
|
|
141
|
+
|
|
142
|
+
path = ::Rails::Hyperdrive::InstallLayout::CONFIG_PATH
|
|
143
|
+
if File.exist?(::Rails.root.join(path))
|
|
144
|
+
say_status :identical, "#{path} (already present)", :blue
|
|
145
|
+
else
|
|
146
|
+
create_file path, CONFIG_TEMPLATE
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
104
150
|
# `--skip-content` writes no lockfile either: the lock is a manifest of
|
|
105
151
|
# installed content, and an empty one would assert "zero files is the
|
|
106
152
|
# managed set". A later init or sync reconstructs the full state.
|
|
@@ -14,7 +14,10 @@ Description:
|
|
|
14
14
|
mutually-exclusive strategies reconcile them with a new upstream:
|
|
15
15
|
--merge three-way-merge the upstream change into the live file
|
|
16
16
|
(clean merges only; anything else falls back to --sidecar,
|
|
17
|
-
so no conflict marker ever lands in a live file)
|
|
17
|
+
so no conflict marker ever lands in a live file). Clean
|
|
18
|
+
means textually non-overlapping, not semantically correct,
|
|
19
|
+
so `git diff` after a --merge run is a review step, not a
|
|
20
|
+
formality.
|
|
18
21
|
--sidecar write the new upstream body to <file>.new next to the live
|
|
19
22
|
file, leaving the live file untouched
|
|
20
23
|
--overwrite restore the live file to the gem-shipped content,
|
|
@@ -25,13 +28,22 @@ Description:
|
|
|
25
28
|
upstream wholesale. Either way the next sync is quiet: the lock already
|
|
26
29
|
records the delivered upstream, so the same version is never re-offered.
|
|
27
30
|
|
|
31
|
+
--resolve does that step for you, git-mergetool style: after delivery it
|
|
32
|
+
hands every unresolved <file>.new to the command in resolve.command in
|
|
33
|
+
.hyperdrive/config.yml and deletes the sidecar when that command exits 0.
|
|
34
|
+
On its own it delivers to sidecars; with --merge, git merges first and the
|
|
35
|
+
command takes the rest. With no command configured it stops before syncing
|
|
36
|
+
anything. A sidecar you edited yourself is never handed off.
|
|
37
|
+
|
|
28
38
|
Touches no bootstrap artifact: .mcp.json, the engine mount, the Gemfile
|
|
29
|
-
plugin directive, and .
|
|
30
|
-
alone.
|
|
39
|
+
plugin directive, .gitignore, and .hyperdrive/config.yml belong to
|
|
40
|
+
hyperdrive:init and are left alone.
|
|
31
41
|
|
|
32
42
|
Examples:
|
|
33
43
|
bin/rails hyperdrive:sync
|
|
34
44
|
bin/rails hyperdrive:sync --merge
|
|
35
45
|
bin/rails hyperdrive:sync --sidecar
|
|
36
46
|
bin/rails hyperdrive:sync --overwrite
|
|
47
|
+
bin/rails hyperdrive:sync --resolve
|
|
48
|
+
bin/rails hyperdrive:sync --merge --resolve
|
|
37
49
|
bin/rails hyperdrive:sync --dry-run
|
|
@@ -18,9 +18,11 @@ module Rails
|
|
|
18
18
|
class_option :merge, type: :boolean, default: false, desc: "Three-way-merge upstream changes into locally-modified files; falls back to sidecar delivery."
|
|
19
19
|
class_option :sidecar, type: :boolean, default: false, desc: "Deliver upstream changes for locally-modified files to <file>.new sidecars."
|
|
20
20
|
class_option :dry_run, type: :boolean, default: false, desc: "Show what would change; write nothing."
|
|
21
|
+
class_option :resolve, type: :boolean, default: false, desc: "Hand each unresolved <file>.new sidecar to the resolver command in .hyperdrive/config.yml; delivers to sidecars unless --merge is given."
|
|
21
22
|
|
|
22
23
|
def verify_options
|
|
23
24
|
chosen = %i[overwrite merge sidecar].select { |flag| options[flag] }
|
|
25
|
+
chosen << :resolve if options[:resolve] && options[:overwrite]
|
|
24
26
|
return if chosen.size <= 1
|
|
25
27
|
raise Thor::Error,
|
|
26
28
|
"hyperdrive: #{chosen.map { |flag| "--#{flag}" }.join(" and ")} are mutually exclusive; pick one"
|
|
@@ -30,6 +32,10 @@ module Rails
|
|
|
30
32
|
runner.verify_environment!
|
|
31
33
|
end
|
|
32
34
|
|
|
35
|
+
def verify_resolver
|
|
36
|
+
runner.verify_resolver! if options[:resolve]
|
|
37
|
+
end
|
|
38
|
+
|
|
33
39
|
def discover_artifacts
|
|
34
40
|
runner.discover_artifacts
|
|
35
41
|
end
|
|
@@ -38,12 +44,16 @@ module Rails
|
|
|
38
44
|
mode =
|
|
39
45
|
if options[:overwrite] then :overwrite
|
|
40
46
|
elsif options[:merge] then :merge
|
|
41
|
-
elsif options[:sidecar] then :sidecar
|
|
47
|
+
elsif options[:sidecar] || options[:resolve] then :sidecar
|
|
42
48
|
else :preserve
|
|
43
49
|
end
|
|
44
50
|
runner.install(mode: mode)
|
|
45
51
|
end
|
|
46
52
|
|
|
53
|
+
def resolve_sidecars
|
|
54
|
+
runner.resolve_sidecars(dry_run: options[:dry_run]) if options[:resolve]
|
|
55
|
+
end
|
|
56
|
+
|
|
47
57
|
def print_summary
|
|
48
58
|
say ""
|
|
49
59
|
say_status :done, "hyperdrive synced", :green
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
require "thor"
|
|
2
2
|
require "rails/hyperdrive"
|
|
3
3
|
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
4
|
+
require "rails/hyperdrive/config_file"
|
|
4
5
|
require "rails/hyperdrive/install_layout"
|
|
5
6
|
require "rails/hyperdrive/install_pipeline"
|
|
6
7
|
require "rails/hyperdrive/lock_file"
|
|
8
|
+
require "rails/hyperdrive/sidecar_resolver"
|
|
7
9
|
require "generators/hyperdrive/install_summary"
|
|
8
10
|
|
|
9
11
|
module Rails
|
|
@@ -13,6 +15,16 @@ module Rails
|
|
|
13
15
|
# `install` forces the ones it needs, so no call order can install with a
|
|
14
16
|
# half-built input set.
|
|
15
17
|
class SyncRunner
|
|
18
|
+
RESOLVER_HELP = <<~MSG.freeze
|
|
19
|
+
--resolve needs a resolver command; add one to %<config>s:
|
|
20
|
+
|
|
21
|
+
resolve:
|
|
22
|
+
command: <your tool> $PROMPT
|
|
23
|
+
|
|
24
|
+
Placeholders: $LOCAL $REMOTE $BASE $MERGED $SOURCE $PREVIOUS_SOURCE $KIND $PROMPT
|
|
25
|
+
(also exported as HYPERDRIVE_* environment variables). Exit 0 marks the sidecar resolved.
|
|
26
|
+
MSG
|
|
27
|
+
|
|
16
28
|
def initialize(shell:, root: nil)
|
|
17
29
|
@shell = shell
|
|
18
30
|
@root = root&.to_s
|
|
@@ -43,17 +55,58 @@ module Rails
|
|
|
43
55
|
shell: @shell,
|
|
44
56
|
artifacts: discover_artifacts,
|
|
45
57
|
mode: mode,
|
|
46
|
-
report: report
|
|
58
|
+
report: report,
|
|
59
|
+
config: config
|
|
47
60
|
)
|
|
48
|
-
@pipeline.call
|
|
61
|
+
@result = @pipeline.call
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Raised before any content write, so --resolve with nothing to run
|
|
65
|
+
# fails without half-syncing the app.
|
|
66
|
+
def verify_resolver!
|
|
67
|
+
return if config.resolve_command
|
|
68
|
+
|
|
69
|
+
raise Thor::Error,
|
|
70
|
+
"hyperdrive: " + format(RESOLVER_HELP, config: ::Rails::Hyperdrive::InstallLayout::CONFIG_PATH)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def resolve_sidecars(dry_run: false)
|
|
74
|
+
@resolve_outcome = ::Rails::Hyperdrive::SidecarResolver.new(
|
|
75
|
+
root: root,
|
|
76
|
+
shell: @shell,
|
|
77
|
+
command: config.resolve_command,
|
|
78
|
+
lock: @pipeline.lock,
|
|
79
|
+
sidecars: Array(@result&.sidecars),
|
|
80
|
+
prompt_path: config.resolve_prompt,
|
|
81
|
+
dry_run: dry_run
|
|
82
|
+
).call
|
|
49
83
|
end
|
|
50
84
|
|
|
51
85
|
def summary_lines
|
|
52
|
-
InstallSummary.lines(lock_entries)
|
|
86
|
+
InstallSummary.lines(lock_entries) + merge_lines + resolve_lines
|
|
53
87
|
end
|
|
54
88
|
|
|
55
89
|
private
|
|
56
90
|
|
|
91
|
+
def merge_lines
|
|
92
|
+
count = Array(@result&.merged).size
|
|
93
|
+
return [] if count.zero?
|
|
94
|
+
|
|
95
|
+
["", " Merged #{InstallSummary.quantify(count, "file")} by three-way merge; a clean merge is " \
|
|
96
|
+
"textually non-overlapping, not verified; review with `git diff`"]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def resolve_lines
|
|
100
|
+
outcome = @resolve_outcome
|
|
101
|
+
return [] unless outcome
|
|
102
|
+
|
|
103
|
+
parts = { "resolved" => outcome.resolved.size, "unresolved" => outcome.unresolved.size,
|
|
104
|
+
"skipped" => outcome.skipped.size }.reject { |_label, count| count.zero? }
|
|
105
|
+
return ["", " No sidecars to resolve"] if parts.empty?
|
|
106
|
+
|
|
107
|
+
["", " Sidecars: #{parts.map { |label, count| "#{count} #{label}" }.join(", ")}"]
|
|
108
|
+
end
|
|
109
|
+
|
|
57
110
|
# Raised from install, so it stops the run before any content write —
|
|
58
111
|
# a dry run included.
|
|
59
112
|
def verify_lock_schema!
|
|
@@ -80,8 +133,14 @@ module Rails
|
|
|
80
133
|
)
|
|
81
134
|
end
|
|
82
135
|
|
|
136
|
+
def config
|
|
137
|
+
@config ||= ::Rails::Hyperdrive::ConfigFile.load(
|
|
138
|
+
File.join(root, ::Rails::Hyperdrive::InstallLayout::CONFIG_PATH)
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
83
142
|
def enabled_gems
|
|
84
|
-
|
|
143
|
+
config.enabled_gems
|
|
85
144
|
end
|
|
86
145
|
|
|
87
146
|
def lock_entries
|