Skip to content

Instantly share code, notes, and snippets.

@jbilbo
Created December 8, 2025 10:01
Show Gist options
  • Select an option

  • Save jbilbo/922ed82e6abd412651b61e68e40b5215 to your computer and use it in GitHub Desktop.

Select an option

Save jbilbo/922ed82e6abd412651b61e68e40b5215 to your computer and use it in GitHub Desktop.
AgentsUnifier - Utility to consolidate AI agent configuration files into AGENTS.md
#!/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