diff --git a/skills/herb-erb-lint-format/SKILL.md b/skills/herb-erb-lint-format/SKILL.md new file mode 100644 index 000000000..5c92d86a3 --- /dev/null +++ b/skills/herb-erb-lint-format/SKILL.md @@ -0,0 +1,206 @@ +--- +name: herb-erb-lint-format +description: Use when editing, creating, refactoring, renaming, or deleting `.html.erb`, `.turbo_stream.erb`, `.html.herb`, or `.rhtml` files in a Rails project that has a `.herb.yml` at the root. Also use when auditing Rails views for accessibility, security, or broken partial references, or when writing template codemods. +--- + +# Lint and format ERB with Herb + +## Overview + +This skill is the working contract for an LLM agent editing ERB inside a Rails app that already has Herb installed. The Herb toolchain provides three independent checks that all need to run after every edit: **lint** (rules), **actionview check** (partial resolution), and optionally **format**. Skipping any of them means shipping the class of bug that tool catches. + +If Herb is not yet installed, use the `herb-install-rails` skill first. + +## The inner loop — run after every ERB edit + +For each modified `.html.erb` / `.turbo_stream.erb` / `.html.herb`: + +```sh +bundle exec herb lint # rule diagnostics +bundle exec herb actionview check # partial resolution (Rails-aware) +bundle exec herb format --check # only if formatter.enabled in .herb.yml +``` + +When renaming, moving, or deleting a partial, also run from the Rails root: + +```sh +bundle exec herb actionview graph # who renders this? +bundle exec herb actionview check . # any unresolved render calls anywhere? +``` + +Fix every `error` and `warning` before declaring the edit done. + +## When the herb binary is missing — DO NOT give up + +If `bundle exec herb` exits with "command not found", **try the npm fallback before stopping**: + +```sh +npx --yes @herb-tools/linter +npx --yes @herb-tools/formatter --check +``` + +The Ruby CLI delegates to these packages anyway. If both routes fail, surface the failure to the user explicitly ("Herb is not runnable in this environment; install it before I edit further") — do not silently skip the inner loop and proceed. + +**`actionview check` has NO npm fallback.** It is a Ruby-only tool that lives in the `herb` gem (it needs Rails view-path resolution). If `bundle exec herb actionview check` fails because the gem isn't installed, do **not** try to invent an npx command for it — there is no `@herb-tools/actionview` package. Surface the failure to the user: "I cannot verify partial references; the herb gem is not installed." + +## Finding partial callers — DO NOT use grep + +Before renaming, moving, or deleting a partial, you need to know who renders it. `grep -r 'render.*card'` looks tempting and is wrong: + +- It misses `render partial: "posts/card"` (different syntax) +- It misses layouts that use the partial via `render template:` +- It can't resolve relative paths (`render "card"` vs `render "posts/card"`) +- It produces false matches in JS, CSS, and unrelated Ruby + +Use the Rails-aware graph instead: + +```sh +bundle exec herb actionview graph app/views/posts/_card.html.erb +``` + +It resolves the same way Rails does. The graph is authoritative; grep is not. + +## Rule playbook + +### Accessibility + +| Rule | Triggers | Fix | +| --- | --- | --- | +| `html-img-require-alt` | `` without `alt` | Add `alt="…"`. Use `alt=""` for purely decorative images. | +| `a11y-disabled-attribute` | `disabled` on non-form elements | Use `aria-disabled="true"`; keep `disabled` on real form controls. | +| `a11y-no-redundant-image-alt` | Alt contains "image of", "picture of"… | Drop the phrase; describe what's depicted. | + +### Security + +| Pattern | Fix | +| --- | --- | +| `
="…">` (ERB in attribute name) | Use `tag.div(**attrs)` or hard-code the attribute. | +| `>` (unquoted attribute value) | Quote it: ``. | +| `<%= raw user_input %>` / `html_safe` on untrusted data | Switch to `<%= … %>` (auto-escaped) or `sanitize` with an allowlist. | + +If a `raw` call is intentional, leave a one-line comment naming the trust boundary; don't disable the rule. + +### Rails conventions + +- Partials should declare locals: `<%# locals: (name:, …) %>`. +- Elements with `data-turbo-permanent` need a stable `id`. +- Prefer `link_to`, `button_to`, `form_with`, `tag.*` over raw HTML when a helper exists. In Rails 7+, `link_to … method: :delete` does not work without rails-ujs — use `button_to` or Turbo's `data: { turbo_method: :delete }`. + +### Style + +`html-tag-name-lowercase`, `erb-require-trailing-newline`, attribute ordering — safe to autofix. + +### Parser errors + +Fix first. Other rules can't run until the file parses. Run `bundle exec herb parse ` to see the syntax error. + +## Autofix policy + +```sh +bundle exec herb lint --fix # safe +bundle exec herb lint --fix-unsafely # may change rendered output +``` + +`--fix-unsafely` requires: clean `git status` for the file beforehand, `git diff` review after, and a smoke render (`bundle exec herb render `) or `bin/rails test` pass. + +## Inline opt-outs — last resort + +```erb +<%# herb:disable rule-name %> + + +<%# herb:linter ignore %> <%# whole file %> +``` + +Every `herb:disable` needs a one-line comment justifying it. Three or more files needing the same disable → propose a `.herb.yml` change or custom rule instead, do not paste the disable. + +## Custom rules + +Project-specific conventions go in `.herb/rules/*.mjs`: + +```js +import { BaseRuleVisitor, ParserRule } from "@herb-tools/linter" + +class Visitor extends BaseRuleVisitor { + visitHTMLOpenTagNode(node) { + if (node.tag_name?.value !== "div") return + this.addOffense("Prefer a semantic element over
.", node.tag_name.location) + } +} + +export default class NoBareDivsRule extends ParserRule { + static ruleName = "no-bare-divs" + check(result, context) { + const v = new Visitor(this.name, context) + v.visit(result.value) + return v.offenses + } +} +``` + +Enable in `.herb.yml`: + +```yaml +linter: + rules: + no-bare-divs: error +``` + +## AST codemods + +When no rule expresses what you need (audits, refactors), use `Herb.parse` — never regex: + +```ruby +require "herb" + +Dir["app/views/**/*.html.erb"].each do |path| + result = Herb.parse(File.read(path)) + next if result.errors.any? + result.value.recursive_visit do |node| + next unless node.respond_to?(:tag_name) && node.tag_name&.value == "img" + attrs = node.attributes.to_h { |a| [a.name&.value, a.value&.value] } + puts "#{path}: missing alt" unless attrs.key?("alt") + end +end +``` + +The same AST powers `herb lint`, the LSP, and `Herb::Engine` — your visitor's results stay consistent with the rest of the toolchain. + +## Red flags — STOP and run the inner loop + +- "`bundle exec herb` isn't installed, so I'll skip lint" → try `npx @herb-tools/linter` first +- "The edit is one line, lint isn't worth it" → run it; one-line edits introduce a11y and security regressions +- "I'll grep for who renders this partial" → use `herb actionview graph` +- "I already ran lint on a similar file" → run it on this file +- "I'll lint all the files at the end after I'm done" → no, after every edit (each edit can introduce a parser error that masks the next) +- "Running bundle install / npx is a heavier side effect than the user asked for" → not running lint is the bigger side effect; tell the user the tool is missing and let them decide +- The agent is reaching for `grep`, `sed`, or `find` to answer a question about ERB structure → use `herb parse`, `herb actionview graph`, or an AST visitor instead + +## Common rationalizations + +| Excuse | Reality | +| --- | --- | +| "Command not found, so lint isn't available" | Fall back to `npx @herb-tools/linter`. Both fail → tell the user, don't silently skip. | +| "grep is faster than `actionview graph`" | grep misses `render partial:`, relative paths, and layouts. Wrong answer fast is worse than right answer slow. | +| "I'll save the lint pass for the final review" | Each edit can introduce a parse error that masks subsequent lint findings. Run after each edit. | +| "The user only asked for a delete button, not a code review" | Lint catches breakage your edit introduced (e.g. an unwrapped ``, missing alt). It's part of doing the edit correctly. | +| "`link_to … method: :delete` works fine" | Not in Rails 7+ without rails-ujs. Use `button_to` or `data: { turbo_method: :delete }`. | +| "I added a `disabled` to a `
` — Herb will catch it" | Yes, if you run it. Run it. | + +## Common failure modes + +| Symptom | Cause | Fix | +| --- | --- | --- | +| Lint exits 0 on a file that's clearly broken | Parser error — other rules suppressed | `bundle exec herb parse ` to see the syntax error | +| `actionview check` reports false positives | `framework: actionview` missing from `.herb.yml` | Add it; rerun | +| Linter ignores a file | Matched by `files.exclude` | `bundle exec herb config .` to see resolved patterns | +| `npx @herb-tools/linter` errors on engine version | Node too old | Use Node 20+ | +| `--fix` rewrote unrelated whitespace | Formatter ran alongside | Run `lint --fix` and `format --check` separately | + +## Reference + +- Rules source: `javascript/packages/linter/src/rules.ts` +- Rule descriptions: `javascript/packages/linter/README.md` +- ActionView analyzer: `lib/herb/action_view/render_analyzer.rb` +- Ruby API: `docs/docs/bindings/ruby/reference.md` +- Full Rails guide: `HERB-IN-RAILS.md` at the repo root diff --git a/skills/herb-install-rails/SKILL.md b/skills/herb-install-rails/SKILL.md new file mode 100644 index 000000000..758e77c76 --- /dev/null +++ b/skills/herb-install-rails/SKILL.md @@ -0,0 +1,117 @@ +--- +name: herb-install-rails +description: Use when adding Herb to a Ruby on Rails app for the first time, when `.herb.yml` is missing in a Rails project, when the user asks to install/configure/wire up Herb, the Herb linter, ActionView render checking, or ReActionView, or when CI for ERB templates needs to be set up in a Rails repo. +--- + +# Install Herb in a Rails app + +## Overview + +Herb is a **hybrid Ruby + Node** toolchain. The `herb` gem ships the CLI, parser, and `Herb::Engine`. The `@herb-tools/*` npm packages ship the linter, formatter, and language server. **`bundle exec herb lint` and `bundle exec herb format` delegate to the npm packages over `npx`.** A Rails CI workflow that only sets up Ruby will silently misbehave — Node must be installed too. + +A single `.herb.yml` configures every tool: CLI, LSP, formatter, linter, `Herb::Engine`, ReActionView. + +## Three things that get installed wrong without this skill + +1. **`framework: actionview` is missing from `.herb.yml`.** Without it, `herb actionview check` (the tool that catches broken partial references) does not work. This is the single most important Rails-specific config key. +2. **CI runs `herb lint` only.** `herb lint` is HTML/ERB rule checking. **Broken partial references are a different tool: `herb actionview check`.** Both belong in CI. +3. **CI sets up Ruby but not Node.** Because the linter delegates to `@herb-tools/linter` via `npx`, Node 20+ is required in the CI runner. + +Do not hand-author `.herb.yml`. Use the writer script below. + +## Lint vs ActionView check — they are different + +| Tool | Catches | Run in CI? | +| --- | --- | --- | +| `bundle exec herb lint` or `npx @herb-tools/linter` | HTML correctness, a11y, security, ERB rules, formatting | Yes | +| `bundle exec herb actionview check .` (Ruby-only — no npm equivalent) | Unresolved `render "foo/bar"` calls, missing partials, unused partials, render cycles | Yes (independent step) | +| `bundle exec herb analyze .` | Parse errors across the project | Optional smoke step | + +If only one is in CI, broken partials WILL ship to production. + +## Workflow + +### 1. Preflight + +Run the doctor. It reports state and never writes anything. + +```sh +skills/herb-install-rails/bin/herb-rails-doctor +``` + +Confirms Rails root, Ruby/Bundler/Node/npx, existing Herb state. Exit 0 = continue. Exit 2 = not a Rails app, stop. + +### 2. Pick a profile + +| Profile | Adds | +| --- | --- | +| `minimal` | `gem "herb"` + `.herb.yml` (`framework: ruby`). No CI, no actionview. Rarely the right choice for a Rails app. | +| `recommended` | `gem "herb"` + `.herb.yml` with `framework: actionview` + GitHub Actions workflow that runs **both** lint and `actionview check`, **with Node setup**. Default. | +| `full` | `recommended` + `gem "reactionview"` (experimental: compile-time HTML/security/a11y validation with in-browser overlay). | + +Default to `recommended`. Pick `full` only if the user explicitly opts into ReActionView. + +### 3. Apply + +```sh +skills/herb-install-rails/bin/herb-rails-setup --profile=recommended +``` + +Idempotent. Edits the Gemfile, runs `bundle install` (non-blocking — config still writes if bundle fails), writes `.herb.yml` via the merge-without-clobber writer, drops `.github/workflows/herb.yml` (which sets up both Ruby AND Node 20, then runs both lint and actionview check), creates `.herb/rules/` for custom rules. + +### 4. Verify + +```sh +skills/herb-install-rails/bin/herb-rails-doctor +bundle exec herb config . # prints the resolved .herb.yml +bundle exec herb analyze . # surfaces existing parse errors (not a setup failure) +bundle exec herb actionview check . # surfaces existing broken partial refs (not a setup failure) +``` + +Existing parse errors and broken partials are a backlog to report to the user, not a sign the install failed. + +## Red flags — stop and re-check + +- `.herb.yml` does not contain `framework: actionview` (and the project is a Rails app) +- The CI workflow runs only `herb lint` (no `actionview check`) +- The CI workflow sets up Ruby but not Node +- You're hand-writing `.herb.yml` instead of running the writer script +- You're guessing the YAML schema instead of reading `lib/herb/defaults.yml` or running the writer +- You're inventing rule names like `erb-no-missing-partial` (the real tool is `actionview check`, not a lint rule) +- The init command you're about to run is `herb init` — that's not the canonical entry point; the writer script or `npx @herb-tools/linter --init` is + +Any of these → stop, run `bin/herb-rails-setup --profile=recommended`. + +## Common rationalizations + +| Excuse | Reality | +| --- | --- | +| "The Ruby gem alone is enough; no Node needed" | `herb lint` / `herb format` delegate to npm packages via npx. CI without Node will not lint correctly. | +| "`herb lint` will catch broken partials" | No. `herb lint` is rule-based. Broken partials are caught by `bundle exec herb actionview check`. Both go in CI. | +| "I'll write the `.herb.yml` by hand — it's just YAML" | The schema is non-obvious (`files.include` is top-level, not nested; `framework: actionview` is mandatory for Rails). Use the writer. | +| "ReActionView is part of the basic install" | It's a separate gem and experimental. Only ship it in the `full` profile when the user opts in. | +| "bundle install failed, so the install failed" | The setup script intentionally continues — `.herb.yml` and CI still get written. Surface the bundle failure to the user; don't wipe progress. | + +## Rollback + +```sh +skills/herb-install-rails/bin/herb-rails-setup --uninstall +``` + +Removes only what this skill created: the gem lines (matched by the marker comment), `.herb.yml`, `.github/workflows/herb.yml`, and `.herb/rules/` if it's empty. + +## Hand-off + +Tell the user, in this order: + +1. The CI workflow runs lint **and** actionview check on every push/PR. Node 20 is required in the workflow (already configured). +2. Local commands: `bundle exec herb lint`, `bundle exec herb actionview check .`, `bundle exec herb analyze .`. +3. Editor LSP: install the Herb extension for VS Code/Cursor; Zed bundles it in the Ruby extension; Neovim uses `herb_ls` via `nvim-lspconfig`. +4. (Full profile only) ReActionView shifts validation to compile time with an in-browser overlay — still experimental. + +## Reference + +- Full Rails+Herb usage guide: `HERB-IN-RAILS.md` at the repo root. +- Canonical config defaults: `lib/herb/defaults.yml`. +- ActionView analyzer source: `lib/herb/action_view/render_analyzer.rb`. +- Once Herb is installed, use the `herb-erb-lint-format` skill for the editing inner loop. diff --git a/skills/herb-install-rails/bin/herb-rails-doctor b/skills/herb-install-rails/bin/herb-rails-doctor new file mode 100755 index 000000000..b97cac866 --- /dev/null +++ b/skills/herb-install-rails/bin/herb-rails-doctor @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# herb-rails-doctor — diagnose Herb readiness for a Rails app. +# Exit 0 if a Rails app is detected (with or without Herb), non-zero otherwise. + +set -u + +root="$(pwd)" +fail=0 +warn=0 + +color() { printf '\033[%sm%s\033[0m' "$1" "$2"; } +ok() { printf ' %s %s\n' "$(color '32' '✓')" "$1"; } +miss() { printf ' %s %s\n' "$(color '33' '∅')" "$1"; warn=$((warn+1)); } +bad() { printf ' %s %s\n' "$(color '31' '✗')" "$1"; fail=$((fail+1)); } +info() { printf ' %s %s\n' "$(color '36' 'ℹ')" "$1"; } + +have() { command -v "$1" >/dev/null 2>&1; } +version_of() { "$1" --version 2>/dev/null | head -n1 || echo "unknown"; } + +echo +echo "Herb Rails Doctor" +echo "=================" +echo "Working dir: $root" +echo + +# Rails detection +echo "Rails app" +if [ -f "$root/Gemfile" ]; then ok "Gemfile present"; else bad "no Gemfile (not a Ruby project)"; fi +if [ -d "$root/app/views" ]; then ok "app/views/ present"; else miss "no app/views/ (is this a Rails app?)"; fi +if [ -f "$root/config/application.rb" ]; then ok "config/application.rb present"; else miss "no config/application.rb"; fi + +if [ "$fail" -gt 0 ]; then + echo + echo "Not a Rails app — stop here." + exit 2 +fi + +# Toolchain +echo +echo "Toolchain" +if have ruby; then ok "ruby: $(version_of ruby)"; else miss "ruby not on PATH (version manager may need activation)"; fi +if have bundle; then ok "bundle: $(version_of bundle)"; else miss "bundle not on PATH"; fi +if have node; then ok "node: $(version_of node)"; else miss "node not on PATH (npx-based tools need it)"; fi +if have npx; then ok "npx: $(version_of npx)"; else miss "npx not on PATH"; fi + +# Herb gem state +echo +echo "Herb" +if grep -E '^\s*gem\s+["'\'']herb["'\'']' "$root/Gemfile" >/dev/null 2>&1; then + ok 'gem "herb" in Gemfile' +else + miss 'gem "herb" not in Gemfile' +fi + +if grep -E '^\s*gem\s+["'\'']reactionview["'\'']' "$root/Gemfile" >/dev/null 2>&1; then + ok 'gem "reactionview" in Gemfile (full profile)' +else + info 'gem "reactionview" not in Gemfile (optional, full profile)' +fi + +if [ -f "$root/.herb.yml" ]; then ok ".herb.yml present"; else miss ".herb.yml missing"; fi +if [ -d "$root/.herb/rules" ]; then ok ".herb/rules/ present (custom rules)"; else info ".herb/rules/ not present (optional)"; fi +if [ -f "$root/.github/workflows/herb.yml" ]; then ok ".github/workflows/herb.yml present"; else miss "no Herb CI workflow"; fi + +# Can the CLI run? +if [ -f "$root/Gemfile.lock" ] && grep -q '^\s*herb\s' "$root/Gemfile.lock" 2>/dev/null; then + ok "herb gem installed (Gemfile.lock)" + if have bundle && bundle exec herb --version >/dev/null 2>&1; then + ok "bundle exec herb runs: $(bundle exec herb --version 2>/dev/null | head -n1)" + else + miss "bundle exec herb does not run yet (try: bundle install)" + fi +else + miss "herb gem not installed (run: bundle install after editing Gemfile)" +fi + +echo +echo "Summary: $warn missing item(s), $fail blocking issue(s)." +echo +exit 0 diff --git a/skills/herb-install-rails/bin/herb-rails-setup b/skills/herb-install-rails/bin/herb-rails-setup new file mode 100755 index 000000000..29c4612d8 --- /dev/null +++ b/skills/herb-install-rails/bin/herb-rails-setup @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# herb-rails-setup — idempotently install and configure Herb in a Rails app. +# +# Usage: +# herb-rails-setup --profile=minimal|recommended|full +# herb-rails-setup --uninstall + +set -euo pipefail + +profile="" +uninstall=0 + +for arg in "$@"; do + case "$arg" in + --profile=*) profile="${arg#*=}";; + --uninstall) uninstall=1;; + -h|--help) + sed -n '2,8p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) echo "unknown argument: $arg" >&2; exit 2;; + esac +done + +here="$(cd "$(dirname "$0")" && pwd)" +root="$(pwd)" + +if [ ! -f "$root/Gemfile" ]; then + echo "no Gemfile in $root — refusing to run." >&2 + exit 2 +fi + +# --- helpers ---------------------------------------------------------------- +add_gem_if_absent() { + local gem_name="$1" + if grep -E "^\s*gem\s+[\"']${gem_name}[\"']" "$root/Gemfile" >/dev/null 2>&1; then + echo " · gem \"$gem_name\" already in Gemfile" + return 1 + fi + printf '\n# Added by herb-rails-setup\ngem "%s"\n' "$gem_name" >> "$root/Gemfile" + echo " + added gem \"$gem_name\" to Gemfile" + return 0 +} + +remove_gem_line() { + local gem_name="$1" + local gemfile="$root/Gemfile" + [ -f "$gemfile" ] || return 0 + # Remove the marker comment + the gem line if both exist together. + ruby -i -ne ' + @buf ||= [] + if $_.start_with?("# Added by herb-rails-setup") + @marker = true + next + end + if @marker && $_ =~ /^gem\s+["'"'"'](herb|reactionview)["'"'"']/ + @marker = false + next + end + @marker = false + print $_ + ' "$gemfile" + echo " - removed $gem_name from Gemfile (if present)" +} + +write_ci_workflow() { + local dest="$root/.github/workflows/herb.yml" + if [ -f "$dest" ]; then + echo " · $dest already exists" + return + fi + mkdir -p "$root/.github/workflows" + cat > "$dest" <<'YAML' +name: Herb +on: [push, pull_request] +jobs: + herb: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - uses: actions/setup-node@v4 + with: + node-version: "20" + - run: bundle exec herb actionview check . + - run: npx --yes @herb-tools/linter +YAML + echo " + wrote $dest" +} + +# --- uninstall -------------------------------------------------------------- +if [ "$uninstall" -eq 1 ]; then + echo "Uninstalling Herb scaffolding (files this skill created only)…" + remove_gem_line "herb" + remove_gem_line "reactionview" + rm -f "$root/.herb.yml" && echo " - removed .herb.yml" || true + rm -f "$root/.github/workflows/herb.yml" && echo " - removed .github/workflows/herb.yml" || true + rmdir "$root/.github/workflows" 2>/dev/null || true + rmdir "$root/.github" 2>/dev/null || true + # Only remove .herb/rules/ if untouched (just the .gitkeep we created) + if [ -d "$root/.herb/rules" ] && [ "$(ls -A "$root/.herb/rules")" = ".gitkeep" ]; then + rm -rf "$root/.herb" + echo " - removed empty .herb/" + fi + echo "Done. Run: bundle install" + exit 0 +fi + +# --- install ---------------------------------------------------------------- +case "$profile" in + minimal|recommended|full) ;; + "") echo "missing --profile=minimal|recommended|full" >&2; exit 2;; + *) echo "invalid profile: $profile" >&2; exit 2;; +esac + +echo "Installing Herb (profile: $profile) in $root" + +# 1. Gemfile +echo "Step 1/4: Gemfile" +gemfile_changed=0 +if add_gem_if_absent "herb"; then gemfile_changed=1; fi +if [ "$profile" = "full" ]; then + if add_gem_if_absent "reactionview"; then gemfile_changed=1; fi +fi + +# 2. bundle install +echo "Step 2/4: bundle install" +if [ "$gemfile_changed" -eq 1 ]; then + if command -v bundle >/dev/null 2>&1; then + if ! bundle install; then + echo " ! bundle install failed — config will still be written; fix bundle and re-run." >&2 + fi + else + echo " ! bundle not on PATH — activate your Ruby version manager and run: bundle install" >&2 + fi +else + echo " · Gemfile unchanged; skipping bundle install" +fi + +# 3. .herb.yml +echo "Step 3/4: .herb.yml" +if command -v ruby >/dev/null 2>&1; then + ruby "$here/herb-rails-write-config.rb" --profile="$profile" --path="$root/.herb.yml" | sed 's/^/ /' +else + echo " ! ruby not on PATH — activate Ruby and re-run." >&2 + exit 3 +fi +mkdir -p "$root/.herb/rules" +[ -e "$root/.herb/rules/.gitkeep" ] || { : > "$root/.herb/rules/.gitkeep"; echo " + created .herb/rules/"; } + +# 4. CI workflow +echo "Step 4/4: CI" +if [ "$profile" = "minimal" ]; then + echo " · skipped (minimal profile)" +else + write_ci_workflow +fi + +echo +echo "Done." +echo "Verify with: skills/herb-install-rails/bin/herb-rails-doctor" +echo "Then run: bundle exec herb config ." + +if [ "$profile" = "full" ]; then + cat <<'NOTE' + +Editor LSP install hints (full profile): + - VS Code / Cursor: install the "Herb LSP" extension + - Zed: bundled in the official Ruby extension + - Neovim: lspconfig.herb_ls.setup({}) (requires nvim-lspconfig) + - Sublime Text: configure via Sublime LSP (see @herb-tools/language-server README) + +ReActionView is experimental — verify the in-browser overlay before relying on it. +NOTE +fi diff --git a/skills/herb-install-rails/bin/herb-rails-write-config.rb b/skills/herb-install-rails/bin/herb-rails-write-config.rb new file mode 100755 index 000000000..0628accde --- /dev/null +++ b/skills/herb-install-rails/bin/herb-rails-write-config.rb @@ -0,0 +1,94 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# herb-rails-write-config — idempotently write or update .herb.yml. +# Existing keys are preserved; only missing keys are filled in. +# +# Usage: +# herb-rails-write-config.rb --profile=minimal|recommended|full [--path=PATH] +# +# Exit codes: +# 0 wrote or updated the file (idempotent) +# 1 invalid arguments / cannot write + +require "yaml" +require "fileutils" +require "optparse" + +profile = "recommended" +path = File.join(Dir.pwd, ".herb.yml") + +OptionParser.new do |opts| + opts.banner = "Usage: herb-rails-write-config.rb [options]" + opts.on("--profile=PROFILE", %w[minimal recommended full]) { |v| profile = v } + opts.on("--path=PATH") { |v| path = v } +end.parse!(ARGV) + +defaults = { + "framework" => (profile == "minimal" ? "ruby" : "actionview"), + "template_engine" => "erubi", + "engine" => { + "validators" => { + "security" => true, + "nesting" => true, + "accessibility" => true, + }, + }, + "linter" => { + "enabled" => true, + "failLevel" => "warning", + "exclude" => ["vendor/**/*", "node_modules/**/*"], + }, + "formatter" => { + "enabled" => false, + "indentWidth" => 2, + "maxLineLength" => 80, + }, +} + +def deep_merge_missing(existing, defaults) + defaults.each do |k, v| + if existing.key?(k) + if existing[k].is_a?(Hash) && v.is_a?(Hash) + deep_merge_missing(existing[k], v) + end + else + existing[k] = v + end + end + existing +end + +load_yaml = lambda do |p| + return {} unless File.exist?(p) + if YAML.respond_to?(:safe_load_file) + YAML.safe_load_file(p) || {} + else + YAML.safe_load(File.read(p)) || {} + end +end +existing = load_yaml.call(path) +unless existing.is_a?(Hash) + warn "#{path} is not a YAML mapping — refusing to overwrite." + exit 1 +end + +before = existing.dup +merged = deep_merge_missing(existing, defaults) +changed = before != merged || !File.exist?(path) + +header = <<~YAML + # Herb configuration. Read by `bundle exec herb`, the LSP, and the @herb-tools/* npm packages. + # See: https://github.com/marcoroth/herb and HERB-IN-RAILS.md +YAML + +FileUtils.mkdir_p(File.dirname(path)) +File.write(path, header + YAML.dump(merged)) + +if changed + puts "wrote #{path} (profile: #{profile})" +else + puts "no changes to #{path} (already configured)" +end + +exit 0