Created
December 8, 2025 10:01
-
-
Save jbilbo/922ed82e6abd412651b61e68e40b5215 to your computer and use it in GitHub Desktop.
AgentsUnifier - Utility to consolidate AI agent configuration files into AGENTS.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S RBENV_VERSION=system ruby | |
| # frozen_string_literal: true | |
| require 'fileutils' | |
| require 'json' | |
| require 'pathname' | |
| class AgentsUnifier | |
| CLAUDE_FALLBACK_CONTENT = <<~MD | |
| # CLAUDE.md | |
| Claude now relies on AGENTS.md for all guidance. This file exists as a fallback. | |
| @AGENTS.md | |
| MD | |
| COPILOT_FALLBACK_CONTENT = <<~MD | |
| # Copilot Instructions | |
| GitHub Copilot now relies on AGENTS.md for all guidance. Update VS Code settings (`chat.useAgentsMdFile`) if needed. | |
| MD | |
| def initialize(project_root) | |
| @project_root = Pathname.new(project_root) | |
| @actions = [] | |
| @warnings = [] | |
| @source_sections = [] | |
| end | |
| def run | |
| check_git_status | |
| gather_sources | |
| validate_migration_safety | |
| ensure_agents_file | |
| ensure_claude_symlink | |
| ensure_copilot_pointer | |
| ensure_gemini_settings | |
| remove_gemini_file | |
| print_summary | |
| end | |
| private | |
| attr_reader :project_root | |
| def check_git_status | |
| git_dir = project_root.join('.git') | |
| unless git_dir.exist? | |
| warn_and_confirm( | |
| 'This project is not under Git version control.', | |
| 'Ensure you have backups before proceeding.' | |
| ) | |
| return | |
| end | |
| begin | |
| status_output = `git -C "#{project_root}" status --porcelain 2>&1` | |
| unless $?.success? | |
| warn_and_confirm( | |
| 'Unable to check Git status.', | |
| 'Ensure all changes are committed before proceeding.' | |
| ) | |
| return | |
| end | |
| unless status_output.strip.empty? | |
| warn_and_confirm( | |
| 'Uncommitted changes detected.', | |
| 'Please commit all changes before running this script.' | |
| ) | |
| end | |
| rescue StandardError => e | |
| warn_and_confirm( | |
| "Failed to check Git status: #{e.message}", | |
| 'Ensure all changes are committed before proceeding.' | |
| ) | |
| end | |
| end | |
| def warn_and_confirm(warning_title, warning_detail) | |
| puts "\nWARNING: #{warning_title}" | |
| puts warning_detail | |
| exit 1 unless ask_confirmation | |
| end | |
| def ask_confirmation | |
| print "Do you want to continue anyway? (yes/no): " | |
| response = $stdin.gets&.strip&.downcase | |
| response == 'yes' || response == 'y' | |
| end | |
| def validate_migration_safety | |
| # Check if AGENTS.md already has content | |
| agents_path = project_root.join('AGENTS.md') | |
| if agents_path.file? | |
| content = safe_read(agents_path) | |
| if content && !content.strip.empty? | |
| abort_with_error("AGENTS.md already has content. This script is intended for initial migration only.\nManual intervention required.") | |
| end | |
| end | |
| # Check if multiple source files have content | |
| if @source_sections.length > 1 | |
| files_with_content = @source_sections.map { |s| s[:label] }.join(', ') | |
| abort_with_error("Multiple source files have content: #{files_with_content}\nThis script migrates from ONE source file only.\nPlease consolidate content manually first.") | |
| end | |
| end | |
| def abort_with_error(message) | |
| puts "\nABORTED: #{message}" | |
| exit 1 | |
| end | |
| def gather_sources | |
| [ | |
| ['GEMINI.md', 'GEMINI.md'], | |
| ['CLAUDE.md', 'CLAUDE.md'], | |
| [File.join('.github', 'copilot-instructions.md'), '.github/copilot-instructions.md'] | |
| ].each do |relative_path, label| | |
| path = project_root.join(relative_path) | |
| next unless path.file? | |
| begin | |
| content = path.read | |
| next if content.strip.empty? | |
| @source_sections << { label: label, content: content.strip } | |
| rescue StandardError => e | |
| @warnings << "Failed to read #{relative_to_root(path)}: #{e.message}" | |
| end | |
| end | |
| end | |
| def ensure_agents_file | |
| path = project_root.join('AGENTS.md') | |
| if path.file? | |
| existing = safe_read(path) | |
| if existing.nil? | |
| @warnings << "Unable to read existing #{relative_to_root(path)}; skipped." | |
| elsif existing.strip.empty? && !@source_sections.empty? | |
| write_file(path, build_agents_content, "Populated #{relative_to_root(path)} with migrated instructions") | |
| else | |
| @actions << "#{relative_to_root(path)} already present" unless existing.nil? | |
| end | |
| return | |
| end | |
| write_file(path, build_agents_content, "Created #{relative_to_root(path)}") | |
| end | |
| def build_agents_content | |
| header = "# Project Instructions\n\n" | |
| return header + "_Add shared instructions here._\n" if @source_sections.empty? | |
| # Single source migration (validated in validate_migration_safety) | |
| source = @source_sections.first | |
| header + "## Migrated from #{source[:label]}\n\n#{source[:content]}\n" | |
| end | |
| def ensure_claude_symlink | |
| relative_target = Pathname.new('../AGENTS.md') | |
| path = project_root.join('.claude', 'CLAUDE.md') | |
| begin | |
| create_symlink(path, relative_target) | |
| rescue StandardError => e | |
| @warnings << "Symlink creation failed for #{relative_to_root(path)}: #{e.message}. Falling back to file content." | |
| update_pointer_file(File.join('.claude', 'CLAUDE.md'), CLAUDE_FALLBACK_CONTENT, | |
| 'Updated Claude instructions file to point to AGENTS.md') | |
| end | |
| # Remove root CLAUDE.md after content has been migrated to AGENTS.md | |
| remove_path('CLAUDE.md', 'Migrated CLAUDE.md content to AGENTS.md; now using .claude/CLAUDE.md') | |
| end | |
| def ensure_copilot_pointer | |
| relative_target = Pathname.new('../AGENTS.md') | |
| path = project_root.join('.github', 'copilot-instructions.md') | |
| begin | |
| create_symlink(path, relative_target) | |
| rescue StandardError => e | |
| @warnings << "Symlink creation failed for #{relative_to_root(path)}: #{e.message}. Falling back to file content." | |
| update_pointer_file(File.join('.github', 'copilot-instructions.md'), COPILOT_FALLBACK_CONTENT, | |
| 'Updated Copilot instructions file to point to AGENTS.md') | |
| end | |
| end | |
| def ensure_gemini_settings | |
| path = project_root.join('.gemini', 'settings.json') | |
| settings = {} | |
| if path.file? | |
| begin | |
| content = path.read | |
| settings = content.strip.empty? ? {} : JSON.parse(content) | |
| rescue JSON::ParserError => e | |
| @warnings << "Invalid JSON in #{relative_to_root(path)}: #{e.message}. Rewriting file." | |
| settings = {} | |
| rescue StandardError => e | |
| @warnings << "Failed to read #{relative_to_root(path)}: #{e.message}" | |
| end | |
| end | |
| return if settings['contextFileName'] == 'AGENTS.md' | |
| settings['contextFileName'] = 'AGENTS.md' | |
| write_json(path, settings, "Updated #{relative_to_root(path)} with contextFileName") | |
| end | |
| def remove_gemini_file | |
| remove_path('GEMINI.md', 'Removed GEMINI.md; Gemini now reads AGENTS.md via settings.json') | |
| end | |
| def update_pointer_file(relative_path, desired_content, action_message) | |
| path = project_root.join(relative_path) | |
| existing = safe_read(path) | |
| return if existing == desired_content | |
| path.dirname.mkpath | |
| path.write(desired_content) | |
| @actions << action_message | |
| rescue StandardError => e | |
| @warnings << "Failed to update #{relative_to_root(path)}: #{e.message}" | |
| end | |
| def create_symlink(path, target_relative) | |
| if path.symlink? | |
| current_target = path.readlink | |
| if current_target == target_relative | |
| @actions << "Symlink already set for #{relative_to_root(path)}" | |
| return | |
| end | |
| path.delete | |
| elsif path.exist? | |
| path.delete | |
| end | |
| path.dirname.mkpath | |
| FileUtils.ln_s(target_relative, path) | |
| @actions << "Created symlink #{relative_to_root(path)} -> #{target_relative}" | |
| end | |
| def safe_read(path) | |
| return nil unless path.file? | |
| path.read | |
| rescue StandardError => e | |
| @warnings << "Failed to read #{relative_to_root(path)}: #{e.message}" | |
| nil | |
| end | |
| def write_file(path, content, action_message) | |
| path.dirname.mkpath | |
| path.write(content) | |
| @actions << action_message | |
| rescue StandardError => e | |
| @warnings << "Failed to write #{relative_to_root(path)}: #{e.message}" | |
| end | |
| def write_json(path, data, action_message) | |
| formatted = JSON.pretty_generate(data) + "\n" | |
| existing = safe_read(path) | |
| return if existing == formatted | |
| path.dirname.mkpath | |
| path.write(formatted) | |
| @actions << action_message | |
| rescue StandardError => e | |
| @warnings << "Failed to write #{relative_to_root(path)}: #{e.message}" | |
| end | |
| def remove_path(relative_path, action_message) | |
| path = project_root.join(relative_path) | |
| return unless path.exist? || path.symlink? | |
| path.delete | |
| @actions << action_message | |
| rescue StandardError => e | |
| @warnings << "Failed to remove #{relative_to_root(path)}: #{e.message}" | |
| end | |
| def relative_to_root(path) | |
| p = Pathname.new(path) | |
| p = p.relative_path_from(project_root) if p.absolute? | |
| p.cleanpath.to_s | |
| rescue ArgumentError, StandardError | |
| path.to_s | |
| end | |
| def print_summary | |
| puts "Unified agent instructions in #{project_root}." | |
| if @actions.empty? | |
| puts '- No changes were necessary.' | |
| else | |
| @actions.each { |msg| puts "- #{msg}" } | |
| end | |
| return if @warnings.empty? | |
| puts "\nWarnings:" | |
| @warnings.each { |msg| puts "- #{msg}" } | |
| end | |
| end | |
| AgentsUnifier.new(Dir.pwd).run |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment