From 8dbc11e3f4087641ce5279b1d93b7131a03850bc Mon Sep 17 00:00:00 2001 From: Lucian Ghinda Date: Fri, 22 May 2026 15:59:06 +0300 Subject: [PATCH] skills: Add Herb install and ERB lint/format skills for LLM agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two skills under skills/ that help LLM agents work with Herb in Rails projects. Built via test-driven skill authoring: baseline subagent scenarios surfaced concrete failure modes (missing framework: actionview, CI lacking Node and actionview check, lint giving up on missing binary instead of trying npx, grep used instead of herb actionview graph), which the skills explicitly address with rationalization tables and red flag lists. Re-tested with the same scenarios after the rewrite and both agents now comply. - skills/herb-install-rails/ — idempotent install/configure flow with bin/herb-rails-doctor (preflight), bin/herb-rails-setup (Gemfile + .herb.yml + CI workflow with both Ruby and Node setup, plus uninstall), and bin/herb-rails-write-config.rb (deep-merge writer that preserves user-set keys, Ruby 2.6+ compatible). - skills/herb-erb-lint-format/ — inner-loop contract for editing ERB: lint + actionview check + format after every edit, with explicit npx fallback when the gem binary is missing and a note that actionview check has no npm equivalent. Co-Authored-By: Claude Opus 4.7 (1M context) --- skills/herb-erb-lint-format/SKILL.md | 206 ++++++++++++++++++ skills/herb-install-rails/SKILL.md | 117 ++++++++++ .../herb-install-rails/bin/herb-rails-doctor | 80 +++++++ .../herb-install-rails/bin/herb-rails-setup | 177 +++++++++++++++ .../bin/herb-rails-write-config.rb | 94 ++++++++ 5 files changed, 674 insertions(+) create mode 100644 skills/herb-erb-lint-format/SKILL.md create mode 100644 skills/herb-install-rails/SKILL.md create mode 100755 skills/herb-install-rails/bin/herb-rails-doctor create mode 100755 skills/herb-install-rails/bin/herb-rails-setup create mode 100755 skills/herb-install-rails/bin/herb-rails-write-config.rb 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