rails-hyperdrive 0.6.0 → 0.7.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.
@@ -4,11 +4,11 @@ module Rails
4
4
  module Hyperdrive
5
5
  # installed_at is volatile metadata, never an input to any comparison.
6
6
  class LockFile
7
- SCHEMA_VERSION = 1
7
+ SCHEMA_VERSION = 2
8
8
  STATE_PRESENT = "present".freeze
9
9
  STATE_REMOVED = "removed-by-user".freeze
10
10
 
11
- DISABLED_KEYS = { skill: "skills", guideline: "guidelines" }.freeze
11
+ DISABLED_KEYS = { skill: "skills", guideline: "guidelines", agent: "agents", command: "commands" }.freeze
12
12
 
13
13
  # In-memory form of one files: entry. On disk, source_gem and
14
14
  # source_version are a single "gem@version" string; the split/join lives
@@ -19,7 +19,7 @@ module Rails
19
19
  end
20
20
  end
21
21
 
22
- attr_reader :path
22
+ attr_reader :path, :schema_version
23
23
  attr_accessor :claude_md_state
24
24
 
25
25
  # Load existing lock state from disk (absent file → empty lock).
@@ -27,6 +27,13 @@ module Rails
27
27
  new(path).tap(&:read)
28
28
  end
29
29
 
30
+ # Every surface reporting an orphan must name the same two cases, or a
31
+ # sync and a bundle install would disagree about why a file is stranded.
32
+ def self.orphan_reason(source_label:, source_gem:, bundled:)
33
+ return "no longer shipped by #{source_label}" unless bundled
34
+ "#{source_gem} is still bundled but did not offer this file"
35
+ end
36
+
30
37
  def initialize(path)
31
38
  @path = path.to_s
32
39
  @claude_md_state = nil # nil = no lock has been written yet
@@ -34,6 +41,7 @@ module Rails
34
41
  @document = {} # raw parsed YAML, kept so unknown keys survive
35
42
  @disabled = empty_disabled
36
43
  @enabled = []
44
+ @schema_version = nil
37
45
  end
38
46
 
39
47
  def read
@@ -43,6 +51,7 @@ module Rails
43
51
  return self unless data.is_a?(Hash)
44
52
 
45
53
  @document = data
54
+ @schema_version = data["version"]
46
55
  claude_md = data["claude_md"]
47
56
  @claude_md_state = claude_md["state"] if claude_md.is_a?(Hash)
48
57
  @disabled = parse_disabled(data["disabled"])
@@ -57,6 +66,19 @@ module Rails
57
66
  self
58
67
  end
59
68
 
69
+ # A lock written by a newer installer holds state this one cannot read,
70
+ # so rewriting it would silently drop the user's settings. A missing or
71
+ # non-numeric version reads as not ahead: every lock ever written carries
72
+ # an integer version, and a hand-broken one must not block a sync.
73
+ def schema_ahead?
74
+ @schema_version.is_a?(Numeric) && @schema_version > SCHEMA_VERSION
75
+ end
76
+
77
+ def schema_ahead_message(display_path)
78
+ "#{display_path} was written by a newer rails-hyperdrive (lock schema #{@schema_version}, " \
79
+ "this installer supports #{SCHEMA_VERSION}); upgrade rails-hyperdrive"
80
+ end
81
+
60
82
  def entry(file_path)
61
83
  @files[file_path.to_s]
62
84
  end
@@ -0,0 +1,230 @@
1
+ require "yaml"
2
+ require "rails/hyperdrive/bundler_artifact_discovery"
3
+ require "rails/hyperdrive/gem_manifest"
4
+ require "rails/hyperdrive/gemspec_locator"
5
+ require "rails/hyperdrive/install_layout"
6
+
7
+ module Rails
8
+ module Hyperdrive
9
+ # Author-side check of a companion gem's hyperdrive.yml, run in the
10
+ # companion's own repo. Where the installer warns and falls open — so that
11
+ # a manifest written for a newer schema never blocks an install — this
12
+ # fails, and unknown keys fail outright: the author's own rails-hyperdrive
13
+ # is the one that knows the whole schema. A manifest that passes draws no
14
+ # gating warning at install time.
15
+ module ManifestLint
16
+ Error = GemspecLocator::Error
17
+
18
+ GATE_KEYS = %w[gem gems hyperdrive_version].freeze
19
+ DIR_KEYS = GemManifest::DIR_KEYS
20
+ ROOT_KEYS = (GATE_KEYS + DIR_KEYS + InstallLayout.content_kinds.map(&:section)).freeze
21
+ SKILL_ENTRY_KEYS = (GATE_KEYS + %w[conditional]).freeze
22
+ CONDITIONAL_ENTRY_KEYS = %w[gem gems].freeze
23
+ RETIRED_VERSION_KEYS = %w[versions version].freeze
24
+ RETIRED_VERSION_HINT = "is not a gating key; put the requirement on the gem: member, " \
25
+ "e.g. `- name: \">= 1.0\"`".freeze
26
+
27
+ # `manifest` is nil when the gem ships none, which is legal.
28
+ Result = Struct.new(:manifest, :problems, keyword_init: true)
29
+
30
+ # Discovery resolves every root against an installed gem's path; in the
31
+ # companion's repo that is the checkout.
32
+ RepoSpec = Struct.new(:name, :full_gem_path, :metadata, keyword_init: true)
33
+
34
+ module_function
35
+
36
+ def check(gemspec: nil, dir: Dir.pwd)
37
+ spec, gem_root = GemspecLocator.load_spec(gemspec, dir)
38
+ relpath = GemManifest.manifest_relpath(spec)
39
+ path = File.join(gem_root, relpath)
40
+ return missing_manifest(spec, relpath) unless File.file?(path)
41
+
42
+ repo_spec = RepoSpec.new(name: spec.name, full_gem_path: gem_root, metadata: spec.metadata || {})
43
+ Result.new(manifest: relpath, problems: problems_in(path, repo_spec))
44
+ end
45
+
46
+ # Shipping no manifest is legal, but a declared path that is not a file
47
+ # leaves the gem opted in as a companion with every artifact ungated —
48
+ # the author-side slip this lint exists to catch.
49
+ def missing_manifest(spec, relpath)
50
+ declared = (spec.metadata || {})[GemManifest::METADATA_KEY].to_s
51
+ return Result.new(manifest: nil, problems: []) unless declared == relpath
52
+
53
+ Result.new(
54
+ manifest: relpath,
55
+ problems: ["gemspec metadata #{GemManifest::METADATA_KEY} names '#{relpath}', which is not a file"]
56
+ )
57
+ end
58
+
59
+ def problems_in(path, repo_spec)
60
+ problems = []
61
+ root = parse_root(path, problems)
62
+ return problems if root.nil?
63
+
64
+ check_keys(root, ROOT_KEYS, "top level", problems)
65
+ check_gate(root, "top level", problems)
66
+ check_dirs(root, problems)
67
+
68
+ manifest = GemManifest.load(repo_spec, warnings: [])
69
+ skills = shipped_skills(repo_spec, manifest)
70
+ InstallLayout.content_kinds.each do |kind|
71
+ shipped = kind.dir_shaped? ? skills.keys : shipped_flat(repo_spec, kind, manifest)
72
+ each_entry(root, kind, shipped, problems) do |key, entry|
73
+ where = "#{kind.section} entry '#{key}'"
74
+ check_keys(entry, kind.dir_shaped? ? SKILL_ENTRY_KEYS : GATE_KEYS, where, problems)
75
+ check_gate(entry, where, problems)
76
+ next unless kind.dir_shaped? && entry.key?("conditional")
77
+ check_conditional(entry["conditional"], where, skills[key], problems)
78
+ end
79
+ end
80
+
81
+ problems
82
+ end
83
+
84
+ # nil means nothing further is readable; an empty manifest is legal.
85
+ def parse_root(path, problems)
86
+ root, failure = GemManifest.read_root(path)
87
+ problems << failure unless root
88
+ root
89
+ end
90
+
91
+ def each_entry(root, kind, shipped, problems)
92
+ value = root[kind.section]
93
+ return if value.nil?
94
+ unless value.is_a?(Hash)
95
+ problems << "#{kind.section}: must be a map of #{kind.shipped_label} keys to gating maps"
96
+ return
97
+ end
98
+
99
+ entries = value.transform_keys(&:to_s)
100
+ check_section_settings(entries, kind, problems)
101
+
102
+ entries.each do |key, entry|
103
+ next if Array(kind.prefix_key).include?(key)
104
+
105
+ unless shipped.include?(key)
106
+ problems << "#{kind.section} entry '#{key}' names no shipped #{kind.shipped_label}"
107
+ end
108
+ unless entry.is_a?(Hash)
109
+ problems << "#{kind.section} entry '#{key}' must be a map of gating keys"
110
+ next
111
+ end
112
+ yield key, entry.transform_keys(&:to_s)
113
+ end
114
+ end
115
+
116
+ # A kind's section-level scalar is a setting, not a gating entry.
117
+ def check_section_settings(entries, kind, problems)
118
+ Array(kind.prefix_key).each do |key|
119
+ next unless entries.key?(key)
120
+
121
+ value = entries[key]
122
+ next if value.is_a?(String) && !value.strip.empty? &&
123
+ !value.match?(%r{[/\\]}) && !value.include?("..")
124
+ problems << "#{kind.section}: #{key}: must be a name prefix with no path separators or '..' segments"
125
+ end
126
+ end
127
+
128
+ def check_keys(map, allowed, where, problems)
129
+ (map.keys - allowed).each do |key|
130
+ problems << if RETIRED_VERSION_KEYS.include?(key)
131
+ "#{where}: '#{key}:' #{RETIRED_VERSION_HINT}"
132
+ else
133
+ "#{where}: unknown key '#{key}'#{did_you_mean(key, allowed)}; " \
134
+ "allowed keys are #{allowed.join(", ")}"
135
+ end
136
+ end
137
+ end
138
+
139
+ def check_dirs(root, problems)
140
+ DIR_KEYS.each do |key|
141
+ next unless root.key?(key)
142
+
143
+ dir, failure = GemManifest.read_dir(root, key)
144
+ next if dir
145
+ problems << "top level: #{failure || "#{key}: must name a directory relative to the gem root"}"
146
+ end
147
+ end
148
+
149
+ def did_you_mean(key, allowed)
150
+ near = allowed.find { |a| a.start_with?(key) || key.start_with?(a) }
151
+ near ? " (did you mean '#{near}'?)" : ""
152
+ end
153
+
154
+ # The parse rules come from GemManifest itself, so a form it accepts is
155
+ # never reported here and a form it falls open on always is. A parser
156
+ # warning counts as a failure: it names a spelling that installs, but not
157
+ # as written.
158
+ def check_gate(map, where, problems, fence: true)
159
+ gem_key = GemManifest.gem_key(map)
160
+ problems << "#{where}: gem: and gems: are aliases; keep only one" if gem_key.conflict
161
+
162
+ if gem_key.present
163
+ parsed = GemManifest.parse_targets(gem_key.value)
164
+ if parsed.nil? || parsed.targets.empty?
165
+ problems << "#{where}: gem: must name #{GemManifest::GEM_FORMS}"
166
+ elsif parsed.warning
167
+ problems << "#{where}: gem: #{parsed.warning}"
168
+ end
169
+ end
170
+
171
+ return unless fence && map.key?("hyperdrive_version")
172
+ return unless GemManifest.malformed_fence?(map["hyperdrive_version"])
173
+ problems << "#{where}: hyperdrive_version: must be a version requirement, e.g. \">= 0.8\""
174
+ end
175
+
176
+ # `shipped` is nil when the owning skills entry names no shipped
177
+ # directory, which is already reported — every key would repeat it.
178
+ def check_conditional(value, where, shipped, problems)
179
+ unless value.is_a?(Hash)
180
+ problems << "#{where}: conditional: must be a map of supporting-file path to a gating map"
181
+ return
182
+ end
183
+
184
+ keys = value.keys.map(&:to_s)
185
+ value.each do |key, entry|
186
+ key = key.to_s
187
+ inner = "#{where} conditional key '#{key}'"
188
+ face = BundlerArtifactDiscovery.target_path(key)
189
+ if BundlerArtifactDiscovery::SKILL_FILE_NAMES.include?(key)
190
+ problems << "#{inner}: the entry's own gem: gates the whole skill"
191
+ elsif shipped && !shipped.include?(key)
192
+ problems << "#{inner}: names no shipped supporting file"
193
+ end
194
+ problems << "#{inner}: '#{face}' keys the same file; keep one spelling" if face != key && keys.include?(face)
195
+
196
+ unless entry.is_a?(Hash)
197
+ problems << "#{inner}: must be a map with gem:"
198
+ next
199
+ end
200
+ entry = entry.transform_keys(&:to_s)
201
+ check_keys(entry, CONDITIONAL_ENTRY_KEYS, inner, problems)
202
+ check_gate(entry, inner, problems, fence: false)
203
+ problems << "#{inner}: gem: is required" unless GemManifest.gem_key(entry).present
204
+ end
205
+ end
206
+
207
+ # Maps each skill's manifest key to the dir-relative paths a conditional:
208
+ # entry may name: every supporting file as shipped, plus the rendered
209
+ # face of each *.md.erb, since either spelling gates the same file.
210
+ def shipped_skills(repo_spec, manifest)
211
+ found = BundlerArtifactDiscovery.skill_paths(repo_spec, manifest: manifest)
212
+ found.each_with_object({}) do |(path, support_root, rel), h|
213
+ shipped = BundlerArtifactDiscovery.support_relpaths(support_root) |
214
+ BundlerArtifactDiscovery.support_relpaths(
215
+ BundlerArtifactDiscovery.template_dir_for(path, support_root), glob: "**/*.md.erb"
216
+ )
217
+ h[rel] = h.fetch(rel, []) | shipped | shipped.map { |p| BundlerArtifactDiscovery.target_path(p) }
218
+ end
219
+ end
220
+
221
+ def shipped_flat(repo_spec, kind, manifest)
222
+ BundlerArtifactDiscovery.flat_paths(repo_spec, kind, manifest: manifest).map { |p| File.basename(p) }
223
+ end
224
+
225
+ private_class_method :missing_manifest, :problems_in, :parse_root, :each_entry, :check_section_settings,
226
+ :check_keys, :check_dirs, :did_you_mean, :check_gate, :check_conditional,
227
+ :shipped_skills, :shipped_flat
228
+ end
229
+ end
230
+ end
@@ -3,8 +3,9 @@ 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 template binding is sealed: exactly three helpers over the resolved
7
- # {gem name => Gem::Version} map, no app context, no arbitrary lookup.
6
+ # The three helpers are the only supported template API, but the binding is
7
+ # an ordinary object rather than a sandbox: a template runs arbitrary Ruby
8
+ # with the privileges of whoever triggered discovery.
8
9
  module SkillTemplate
9
10
  class Context
10
11
  def initialize(resolved)
@@ -31,9 +32,10 @@ module Rails
31
32
  end
32
33
  end
33
34
 
34
- # Fail-open binding for the canonical render: every gem reads as present
35
- # at any requested version, so the output includes every conditional
36
- # branch. There is no bundle to resolve against, so gem_version is nil.
35
+ # Canonical render binding: every gem reads as present at any requested
36
+ # version, so an if/elsif/else contributes only its first branch and
37
+ # templates meant for this render must use additive if blocks. There is
38
+ # no bundle to resolve against, so gem_version is nil.
37
39
  class CanonicalContext
38
40
  def gem?(_name, _requirement = nil)
39
41
  true
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Hyperdrive
3
- VERSION = "0.6.0"
3
+ VERSION = "0.7.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-hyperdrive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bakaface
@@ -69,8 +69,8 @@ description: |
69
69
  Rails Hyperdrive mounts an MCP (Model Context Protocol) server at /_hyperdrive/mcp in development,
70
70
  exposing introspection tools for AI coding agents (eval Ruby, query DB, tail logs,
71
71
  list models/routes, locate source, fetch docs, snapshot stack). It also ships a
72
- `hyperdrive:init` generator that discovers and installs two artifact typeslazy skills
73
- and eager guidelines — shipped by companion gems under a documented contract.
72
+ `hyperdrive:init` generator that discovers and installs four artifact kinds — skills,
73
+ guidelines, agents, and commands — shipped by companion gems under a documented contract.
74
74
  rails-hyperdrive is the mechanism; companion gems (rails-hyperdrive-<library>) are the content.
75
75
  email:
76
76
  - afaceisnomore@gmail.com
@@ -93,7 +93,9 @@ files:
93
93
  - lib/generators/hyperdrive/sync/USAGE
94
94
  - lib/generators/hyperdrive/sync/sync_generator.rb
95
95
  - lib/generators/hyperdrive/sync_runner.rb
96
+ - lib/hyperdrive/skill_tasks.rb
96
97
  - lib/rails-hyperdrive.rb
98
+ - lib/rails/commands/hyperdrive/hyperdrive_command.rb
97
99
  - lib/rails/hyperdrive.rb
98
100
  - lib/rails/hyperdrive/ancestor_locator.rb
99
101
  - lib/rails/hyperdrive/artifact_status.rb
@@ -107,17 +109,18 @@ files:
107
109
  - lib/rails/hyperdrive/eager_footprint.rb
108
110
  - lib/rails/hyperdrive/engine.rb
109
111
  - lib/rails/hyperdrive/gem_manifest.rb
112
+ - lib/rails/hyperdrive/gemspec_locator.rb
110
113
  - lib/rails/hyperdrive/index_document.rb
111
114
  - lib/rails/hyperdrive/install_layout.rb
112
115
  - lib/rails/hyperdrive/install_pipeline.rb
113
116
  - lib/rails/hyperdrive/install_plan.rb
114
117
  - lib/rails/hyperdrive/install_shell.rb
115
118
  - lib/rails/hyperdrive/lock_file.rb
119
+ - lib/rails/hyperdrive/manifest_lint.rb
116
120
  - lib/rails/hyperdrive/mcp_server.rb
117
121
  - lib/rails/hyperdrive/resources/skill.rb
118
122
  - lib/rails/hyperdrive/resources/stack_profile.rb
119
123
  - lib/rails/hyperdrive/safety/rack_middleware.rb
120
- - lib/rails/hyperdrive/skill_tasks.rb
121
124
  - lib/rails/hyperdrive/skill_template.rb
122
125
  - lib/rails/hyperdrive/sql_safety.rb
123
126
  - lib/rails/hyperdrive/stack_profile.rb
@@ -132,7 +135,6 @@ files:
132
135
  - lib/rails/hyperdrive/tools/run_sql.rb
133
136
  - lib/rails/hyperdrive/tools/tail_logs.rb
134
137
  - lib/rails/hyperdrive/version.rb
135
- - lib/tasks/hyperdrive.rake
136
138
  homepage: https://github.com/rails-hyperdrive/rails-hyperdrive
137
139
  licenses:
138
140
  - MIT
@@ -158,6 +160,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
158
160
  requirements: []
159
161
  rubygems_version: 3.6.9
160
162
  specification_version: 4
161
- summary: Dev-only Rails engine that bootstraps an MCP server + skills/guidelines for
162
- AI coding agents.
163
+ summary: Dev-only Rails engine that bootstraps an MCP server + stack-matched skills,
164
+ guidelines, agents, and commands for AI coding agents.
163
165
  test_files: []
@@ -1,27 +0,0 @@
1
- # Rake tasks for companion gem repos: add `require "rails/hyperdrive/skill_tasks"`
2
- # to the Rakefile. Both tasks take an optional gemspec-path argument, e.g.
3
- # rake "hyperdrive:skills:render[path/to/name.gemspec]".
4
- require "rake"
5
- require "rails/hyperdrive/canonical_skill_render"
6
-
7
- namespace :hyperdrive do
8
- namespace :skills do
9
- desc "Render each SKILL.md.erb template to its canonical static SKILL.md"
10
- task :render, [:gemspec] do |_t, args|
11
- written = Rails::Hyperdrive::CanonicalSkillRender.write(gemspec: args[:gemspec])
12
- written.each { |r| puts "render #{r.dest}" }
13
- puts "no skill templates found" if written.empty?
14
- end
15
-
16
- desc "Fail when a canonical SKILL.md is stale relative to its template"
17
- task :check, [:gemspec] do |_t, args|
18
- stale = Rails::Hyperdrive::CanonicalSkillRender.stale(gemspec: args[:gemspec])
19
- if stale.empty?
20
- puts "canonical skill files up to date"
21
- else
22
- stale.each { |r| warn "stale: #{r.dest}" }
23
- abort "#{stale.size} canonical skill file(s) stale; run `rake hyperdrive:skills:render`"
24
- end
25
- end
26
- end
27
- end
@@ -1,22 +0,0 @@
1
- namespace :hyperdrive do
2
- desc "Install Rails Hyperdrive into this app (writes .mcp.json, CLAUDE.md, skills, guidelines, mounts engine)"
3
- task :init do
4
- require "rails/generators"
5
- require "generators/hyperdrive/install/install_generator"
6
- Rails::Generators::Hyperdrive::InstallGenerator.start(ARGV.drop(1))
7
- end
8
-
9
- desc "Sync Rails Hyperdrive content (skills, guidelines, index.md, lockfile); locally-edited files are preserved — pass --merge (three-way merge), --sidecar (deliver upstream to <file>.new), or --overwrite (restore gem-shipped content)"
10
- task :sync do
11
- require "rails/generators"
12
- require "generators/hyperdrive/sync/sync_generator"
13
- Rails::Generators::Hyperdrive::SyncGenerator.start(ARGV.drop(1))
14
- end
15
-
16
- desc "Suggest uninstalled rails-hyperdrive companion gems for this app's stack (networked, cached; pass --refresh to re-query)"
17
- task :discover do
18
- require "rails/generators"
19
- require "generators/hyperdrive/discover/discover_generator"
20
- Rails::Generators::Hyperdrive::DiscoverGenerator.start(ARGV.drop(1))
21
- end
22
- end