Class: RailsAiContext::Generators::InstallGenerator

Inherits:
Rails::Generators::Base
  • Object
show all
Defined in:
lib/generators/rails_ai_context/install/install_generator.rb

Constant Summary collapse

AI_TOOLS =
{
  "1" => { key: :claude,   name: "Claude Code",     files: "CLAUDE.md + .claude/rules/",                        format: :claude },
  "2" => { key: :cursor,   name: "Cursor",          files: ".cursor/rules/ + .cursorrules (legacy fallback)",    format: :cursor },
  "3" => { key: :copilot,  name: "GitHub Copilot",  files: ".github/copilot-instructions.md + .github/instructions/", format: :copilot },
  "4" => { key: :opencode, name: "OpenCode",        files: "AGENTS.md",                                          format: :opencode },
  "5" => { key: :codex,   name: "Codex CLI",       files: "AGENTS.md + .codex/config.toml",                     format: :codex }
}.freeze
FORMAT_PATHS =

Files/dirs generated per AI tool format - used for cleanup on tool removal. MCP config files are NOT listed here - they use merge-safe removal via McpConfigGenerator.remove to preserve other servers' entries.

{
  claude:   %w[CLAUDE.md .claude/rules],
  cursor:   %w[.cursor/rules .cursorrules],
  copilot:  %w[.github/copilot-instructions.md .github/instructions],
  opencode: %w[AGENTS.md app/models/AGENTS.md app/controllers/AGENTS.md],
  codex:    %w[AGENTS.md app/models/AGENTS.md app/controllers/AGENTS.md]
}.freeze
BARE_GUARD_PATTERN =

The initializer guard written before this gem checked respond_to?(:configure). A path:/git: gemspec is evaluated in-process by Bundler in every environment, defining a VERSION-only stub RailsAiContext module even when the gem itself isn't in the current Bundler group - so defined?(RailsAiContext) alone doesn't prove .configure exists.

/^([ \t]*)if defined\?\(RailsAiContext\)$/
CONFIG_SECTIONS =

All config sections with their marker comment and content. Each section is identified by its marker (e.g., "── AI Tools ──"). On re-install, only sections NOT already present are appended.

{
  "AI Tools" => <<~SECTION,
  "Introspection" => <<~SECTION,
  "Models & Filtering" => <<~SECTION,
  "MCP Server" => <<~SECTION,
  "File Size Limits" => <<~SECTION,
  "Extensibility" => <<~SECTION,
  "Security" => <<~SECTION,
  "Database Query Tool" => <<~SECTION,
  "Log Reading" => <<~SECTION,
  "Hydration" => <<~SECTION,
  "Search" => <<~SECTION,
  "Frontend" => <<~SECTION
      # ── Frontend Framework Detection ─────────────────────────────────
      # Auto-detected from package.json, config/vite.json, etc. Override only if needed.
      # config.frontend_paths = ["app/frontend", "../web-client"]
  SECTION
}.freeze

Instance Method Summary collapse

Instance Method Details

#add_to_gitignoreObject



573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
# File 'lib/generators/rails_ai_context/install/install_generator.rb', line 573

def add_to_gitignore
  gitignore = Rails.root.join(".gitignore")
  return unless File.exist?(gitignore)

  content = File.read(gitignore)
  lines = []
  unless content.include?(".ai-context.json")
    lines << ""
    lines << "# rails-ai-context (JSON cache - markdown files should be committed)"
    lines << ".ai-context.json"
  end
  unless content.include?(".codex/config.toml")
    lines << ""
    lines << "# rails-ai-context (embeds this machine's Ruby PATH/GEM_HOME - do not share)"
    lines << ".codex/config.toml"
  end

  if lines.any?
    File.open(gitignore, "a") { |f| lines.each { |line| f.puts line } }
    say "Updated .gitignore", :green
  end
end

#cleanup_removed_toolsObject



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/generators/rails_ai_context/install/install_generator.rb', line 96

def cleanup_removed_tools
  @previous_formats = read_previous_ai_tools
  return unless @previous_formats&.any?

  removed = @previous_formats - @selected_formats
  return if removed.empty?

  say ""
  say "These AI tools were removed from your selection:", :yellow
  removed.each_with_index do |fmt, idx|
    tool = AI_TOOLS.values.find { |t| t[:format] == fmt }
    say "  #{idx + 1}. #{tool[:name]} (#{tool[:files]})" if tool
  end
  say ""

  say "Remove their generated files?", :yellow
  say "  y - remove all listed above"
  say "  n - keep all (default)"
  say "  1,2 - remove only specific ones by number"
  say ""

  input = ask_safe("Enter choice:").strip.downcase
  return if input.empty? || input == "n" || input == "no"

  to_remove = if input == "y" || input == "yes" || input == "a"
    removed
  else
    nums = input.split(/[\s,]+/).filter_map { |n| n.to_i - 1 }
    nums.filter_map { |i| removed[i] if i >= 0 && i < removed.size }
  end

  return if to_remove.empty?

  # Collect paths still needed by remaining tools to avoid deleting shared files
  kept_paths = @selected_formats.flat_map { |f| FORMAT_PATHS[f] || [] }.to_set

  to_remove.each do |fmt|
    tool = AI_TOOLS.values.find { |t| t[:format] == fmt }

    # Remove context files (skip if another selected tool still needs them)
    paths = FORMAT_PATHS[fmt] || []
    paths.each do |rel_path|
      next if kept_paths.include?(rel_path)

      full = Rails.root.join(rel_path)
      if File.directory?(full)
        FileUtils.rm_rf(full)
        say "  Removed #{rel_path}/", :red
      elsif File.exist?(full)
        FileUtils.rm_f(full)
        say "  Removed #{rel_path}", :red
      end
    end

    # Merge-safe MCP config cleanup - removes only the rails-ai-context entry
    cleaned = RailsAiContext::McpConfigGenerator.remove(tools: [ fmt ], output_dir: Rails.root.to_s)
    cleaned.each { |f| say "  Removed MCP entry from #{Pathname.new(f).relative_path_from(Rails.root)}", :red }

    say "#{tool[:name]} files removed", :green if tool
  end
end

#create_initializerObject



351
352
353
354
355
356
357
358
359
360
# File 'lib/generators/rails_ai_context/install/install_generator.rb', line 351

def create_initializer
  initializer_path = "config/initializers/rails_ai_context.rb"
  full_path = Rails.root.join(initializer_path)

  if File.exist?(full_path)
    update_existing_initializer(full_path)
  else
    create_new_initializer(initializer_path)
  end
end

#create_mcp_configObject



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/generators/rails_ai_context/install/install_generator.rb', line 177

def create_mcp_config
  # No explicit standalone: flag - the generator detects the install
  # mode from Gemfile.lock, so this writes the same command form as the
  # standalone CLI init for the same app (no config ping-pong).
  generator = RailsAiContext::McpConfigGenerator.new(
    tools: @selected_formats,
    output_dir: Rails.root.to_s,
    tool_mode: @tool_mode
  )
  result = generator.call
  result[:written].each do |f|
    rel = Pathname.new(f).relative_path_from(Rails.root)
    say "Created/Updated #{rel}", :green
  end
  result[:skipped].each do |f|
    rel = Pathname.new(f).relative_path_from(Rails.root)
    say "#{rel} unchanged - skipped", :yellow
  end
  if @tool_mode == :cli
    say "Skipped MCP config files (CLI-only mode)", :yellow
  end
end

#create_yaml_configObject

no_tasks



554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
# File 'lib/generators/rails_ai_context/install/install_generator.rb', line 554

def create_yaml_config
  yaml_path = Rails.root.join(".rails-ai-context.yml")
  content = {
    "ai_tools" => @selected_formats.map(&:to_s),
    "tool_mode" => @tool_mode.to_s
  }

  require "yaml"
  new_content = YAML.dump(content)
  existed = File.exist?(yaml_path)

  if existed && File.read(yaml_path) == new_content
    say ".rails-ai-context.yml (unchanged)", :yellow
  else
    File.write(yaml_path, new_content)
    say "#{existed ? 'Updated' : 'Created'} .rails-ai-context.yml", :green
  end
end

#generate_context_filesObject



652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
# File 'lib/generators/rails_ai_context/install/install_generator.rb', line 652

def generate_context_files
  say ""
  say "Generating AI context files...", :yellow

  unless Rails.application
    say "  Skipped (Rails app not fully loaded). Run `rails ai:context` after install.", :yellow
    return
  end

  require "rails_ai_context"

  # One-time v5.0.0 legacy UI-pattern files cleanup prompt
  RailsAiContext::LegacyCleanup.prompt_legacy_files(
    @selected_formats, root: Rails.root
  )

  # Generate every selected format in ONE call so ContextFileSerializer's
  # cross-format dedup applies (opencode and codex share AGENTS.md and
  # its split rules - generating them one format at a time defeats that
  # dedup and reports the same file as both written and unchanged).
  begin
    result = RailsAiContext.generate_context(format: @selected_formats)
    (result[:written] || []).each { |f| say "#{f}", :green }
    (result[:skipped] || []).each { |f| say "  ⏭️  #{f} (unchanged)", :yellow }
  rescue => e
    say "#{@selected_formats.join(', ')}: #{e.message}", :red
  end
end

#install_validation_hookObject



596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
# File 'lib/generators/rails_ai_context/install/install_generator.rb', line 596

def install_validation_hook
  git_dir = Rails.root.join(".git")
  return unless Dir.exist?(git_dir)

  hooks_dir = git_dir.join("hooks")
  hook_path = hooks_dir.join("pre-commit")

  if File.exist?(hook_path) && !File.read(hook_path).include?("rails-ai-context")
    say "  Skipped pre-commit hook (existing hook found - add manually)", :yellow
    return
  end

  return if File.exist?(hook_path) && File.read(hook_path).include?("rails-ai-context")

  answer = ask_safe("Install a pre-commit hook that validates Rails references? (y/N)").strip.downcase
  return unless answer == "y"

  # Standalone installs have no `ai:*` rake tasks, so the hook must call
  # the gem's own binary; in-Gemfile installs go through rake as usual.
  if RailsAiContext::InstallMode.standalone?
    hook_binary = "rails-ai-context"
    validate_command = %(rails-ai-context tool validate --files "$files")
  else
    hook_binary = "rails"
    validate_command = %(rails 'ai:tool[validate]' files="$files")
  end

  FileUtils.mkdir_p(hooks_dir)
  File.write(hook_path, <<~HOOK)
    #!/bin/bash
    # rails-ai-context: validate Rails references before commit
    # Catches hallucinated columns, missing models, and schema drift.
    # Remove this file or the rails-ai-context section to disable.

    changed_files=$(git diff --cached --name-only | grep -E '\\.(rb|erb)$' || true)

    if [ -z "$changed_files" ]; then
      exit 0
    fi

    if command -v #{hook_binary} &> /dev/null; then
      files=$(printf '%s\\n' "$changed_files" | tr '\\n' ',')
      #{validate_command} 2>/dev/null
      exit_code=$?
      if [ $exit_code -ne 0 ]; then
        echo ""
        echo "rails-ai-context validation found issues."
        echo "Fix them or skip with: git commit --no-verify"
        exit $exit_code
      fi
    fi
  HOOK
  FileUtils.chmod(0o755, hook_path)
  say "  Installed pre-commit validation hook", :green
end

#select_ai_toolsObject



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/generators/rails_ai_context/install/install_generator.rb', line 67

def select_ai_tools
  say ""
  say "Which AI tools do you use? (select all that apply)", :yellow
  say ""
  AI_TOOLS.each do |num, info|
    say "  #{num}. #{info[:name].ljust(16)}#{info[:files]}"
  end
  say "  a. All of the above"
  say ""

  input = ask_safe("Enter numbers separated by commas (e.g. 1,2) or 'a' for all:").strip.downcase

  @selected_formats = if input == "a" || input == "all"
    AI_TOOLS.values.map { |t| t[:format] }
  else
    nums = input.split(/[\s,]+/)
    nums.filter_map { |n| AI_TOOLS[n]&.dig(:format) }
  end

  if @selected_formats.empty?
    say "No tools selected - defaulting to all.", :yellow
    @selected_formats = AI_TOOLS.values.map { |t| t[:format] }
  end

  selected_names = AI_TOOLS.values.select { |t| @selected_formats.include?(t[:format]) }.map { |t| t[:name] }
  say ""
  say "Selected: #{selected_names.join(', ')}", :green
end

#select_tool_modeObject



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/generators/rails_ai_context/install/install_generator.rb', line 158

def select_tool_mode
  say ""
  say "Do you also want MCP server support?", :yellow
  say ""
  say "  1. Yes - MCP primary + CLI fallback (generates per-tool MCP config files)"
  say "  2. No  - CLI only (no server needed)"
  say ""

  input = ask_safe("Enter number (default: 1):").strip

  @tool_mode = case input
  when "2" then :cli
  else :mcp
  end

  mode_label = @tool_mode == :mcp ? "MCP + CLI fallback" : "CLI only"
  say "Selected: #{mode_label}", :green
end

#show_instructionsObject



681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
# File 'lib/generators/rails_ai_context/install/install_generator.rb', line 681

def show_instructions
  say ""
  say "=" * 50, :cyan
  say " rails-ai-context installed!", :cyan
  say "=" * 50, :cyan
  say ""
  say "Your setup:", :yellow
  AI_TOOLS.each_value do |info|
    next unless @selected_formats.include?(info[:format])
    say "#{info[:name].ljust(16)}#{info[:files]}"
  end
  say ""
  say "Commands:", :yellow
  say "  rails ai:context         # Regenerate context files"
  tool_count = RailsAiContext::Server.builtin_tools.size
  say "  rails 'ai:tool[schema]'    # Run any of the #{tool_count} tools from CLI"
  if @tool_mode == :mcp
    say "  rails ai:serve           # Start MCP server (#{tool_count} live tools)"
  end
  say "  rails ai:facts           # Print concise schema facts summary"
  say "  rails 'ai:preset[arch]'   # Run multi-tool presets (architecture, debugging, migration)"
  say "  rails ai:doctor          # Check AI readiness"
  say "  rails ai:inspect         # Print introspection summary"
  say ""
  if @tool_mode == :mcp
    say "MCP auto-discovery:", :yellow
    say "  Each AI tool gets its own config file - auto-detected on project open."
    say "  No manual config needed."
  else
    say "CLI tools:", :yellow
    say "  AI agents can run `rails 'ai:tool[schema]' table=users` directly."
    say "  No MCP server needed - tools work from the terminal."
  end
  say ""
  say "To add more AI tools later:", :yellow
  say "  rails ai:context:cursor   # Generate for Cursor"
  say "  rails ai:context:copilot  # Generate for Copilot"
  say "  rails generate rails_ai_context:install  # Re-run to pick tools"
  say ""
  say "Standalone (no Gemfile needed):", :yellow
  say "  gem install rails-ai-context"
  say "  rails-ai-context init          # interactive setup"
  say "  rails-ai-context serve         # start MCP server"
  say ""
  if @selected_formats.include?(:codex)
    say "Commit context files and MCP configs so your team benefits! (.codex/config.toml stays local - it embeds machine-specific paths; add it to .gitignore)", :green
  else
    say "Commit context files and MCP config files so your team benefits!", :green
  end
end