Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save zetavg/6c66232174bbaf3c132f176f11339751 to your computer and use it in GitHub Desktop.
Save zetavg/6c66232174bbaf3c132f176f11339751 to your computer and use it in GitHub Desktop.
A script that creates symlinks from GitHub Copilot instruction files to Claude Code memory files (CLAUDE.md), allowing you to share the same instructions between both AI coding assistants.
#!/usr/bin/env node
/**
* Creates symlinks from GitHub Copilot instruction files to Claude Code memory files (CLAUDE.md), allowing you to share the same instructions between both AI coding assistants.
*
* Quick usage:
*
* ```bash
* curl -s https://gist.githubusercontent.com/zetavg/6c66232174bbaf3c132f176f11339751/raw/github-copilot-instructions-to-claude-code-memories.mjs | node --input-type=module
* ```
*
* Ref:
* * https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions#creating-a-repository-custom-instructions-file
* * https://docs.anthropic.com/en/docs/claude-code/memory
*
* Co-authored by Claude Opus 4.
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
// ANSI color codes for better output
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m'
};
function log(message, color = 'reset') {
console.info(`${colors[color]}${message}${colors.reset}`);
}
// Parse frontmatter from markdown file
function parseFrontmatter(content) {
const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n/;
const match = content.match(frontmatterRegex);
if (!match) {
return null;
}
const frontmatterContent = match[1];
const applyToMatch = frontmatterContent.match(/applyTo:\s*["']?([^"'\n]+)["']?/);
return applyToMatch ? applyToMatch[1].trim() : null;
}
// Convert glob pattern to directory path
function globToDirectory(globPattern) {
// Handle the most common patterns
if (globPattern === '**' || globPattern === '**/*') {
return '.';
}
// Handle patterns like "app/models/**/*.rb" -> "app/models"
const match = globPattern.match(/^([^*]+)\/\*\*/);
if (match) {
return match[1];
}
// Handle patterns like "src/*.js" -> "src"
const dirMatch = globPattern.match(/^([^*]+)\/\*/);
if (dirMatch) {
return dirMatch[1];
}
// If pattern doesn't contain wildcards and is a directory path
if (!globPattern.includes('*') && !globPattern.includes('?')) {
return globPattern;
}
return null;
}
// Check if file exists
async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
// Check if file is a symlink
async function isSymlink(filePath) {
try {
const stats = await fs.lstat(filePath);
return stats.isSymbolicLink();
} catch {
return false;
}
}
// Check if path is a symlink pointing to target
async function isSymlinkTo(linkPath, targetPath) {
try {
const stats = await fs.lstat(linkPath);
if (!stats.isSymbolicLink()) {
return false;
}
const linkTarget = await fs.readlink(linkPath);
const resolvedTarget = path.resolve(path.dirname(linkPath), linkTarget);
const resolvedExpected = path.resolve(targetPath);
return resolvedTarget === resolvedExpected;
} catch {
return false;
}
}
// Backup existing CLAUDE.md file
async function backupExistingFile(filePath, targetPath) {
if (!await fileExists(filePath)) {
return;
}
// Check if it's already a symlink to the correct target
if (await isSymlinkTo(filePath, targetPath)) {
log(` Skipping ${filePath} - already linked to correct target`, 'blue');
return;
}
let backupPath = filePath.replace(/\.md$/, '.bak.md');
let counter = 2;
while (await fileExists(backupPath)) {
backupPath = filePath.replace(/\.md$/, `.bak${counter}.md`);
counter++;
}
await fs.rename(filePath, backupPath);
log(` Backed up existing ${filePath} to ${backupPath}`, 'yellow');
}
// Create symlink
async function createSymlink(target, linkPath) {
// Ensure directory exists
const linkDir = path.dirname(linkPath);
await fs.mkdir(linkDir, { recursive: true });
// Create relative path for the symlink
const relativeTarget = path.relative(linkDir, target);
// Backup existing file if needed (pass the target path)
await backupExistingFile(linkPath, target);
// Check if symlink already exists with correct target
if (await isSymlinkTo(linkPath, target)) {
return; // Already correctly linked
}
// Create symlink
try {
await fs.symlink(relativeTarget, linkPath);
log(` Created symlink: ${linkPath} -> ${relativeTarget}`, 'green');
} catch (error) {
log(` Failed to create symlink ${linkPath}: ${error.message}`, 'red');
}
}
// Main function
async function main() {
const cwd = process.cwd();
log('GitHub Copilot Instructions to Claude Code Memories Linker', 'cyan');
log(`Working directory: ${cwd}\n`, 'blue');
const mappings = [];
const warnings = [];
// Check for .github/copilot-instructions.md
const copilotInstructionsPath = path.join(cwd, '.github', 'copilot-instructions.md');
if (await fileExists(copilotInstructionsPath)) {
// Only add to mappings if it's not a symlink itself
if (!await isSymlink(copilotInstructionsPath)) {
log(' Found .github/copilot-instructions.md', 'cyan');
const claudePath = path.join(cwd, 'CLAUDE.md');
mappings.push({
instructionFile: copilotInstructionsPath,
applyTo: '**',
directory: '.',
claudePath
});
} else {
log(' Skipping .github/copilot-instructions.md (it is a symlink)', 'yellow');
}
}
// Check for .github/instructions/*.instructions.md
const instructionsDir = path.join(cwd, '.github', 'instructions');
try {
const files = await fs.readdir(instructionsDir);
const instructionFiles = files.filter(f => f.endsWith('.instructions.md'));
if (instructionFiles.length > 0) {
log(` Found ${instructionFiles.length} instruction file(s) in .github/instructions/`, 'cyan');
// Parse all instruction files
for (const file of instructionFiles) {
const filePath = path.join(instructionsDir, file);
// Skip if it's a symlink
if (await isSymlink(filePath)) {
log(`Skipping ${file} (it is a symlink)`, 'yellow');
continue;
}
const content = await fs.readFile(filePath, 'utf-8');
const applyTo = parseFrontmatter(content);
if (!applyTo) {
warnings.push(`File ${file} has no 'applyTo' frontmatter`);
continue;
}
const directory = globToDirectory(applyTo);
if (directory === null) {
warnings.push(`Unsupported glob pattern in ${file}: "${applyTo}"`);
continue;
}
const claudePath = directory === '.'
? path.join(cwd, 'CLAUDE.md')
: path.join(cwd, directory, 'CLAUDE.md');
mappings.push({
instructionFile: filePath,
applyTo,
directory,
claudePath
});
}
}
} catch (error) {
if (error.code !== 'ENOENT') {
log(`Error reading .github/instructions/: ${error.message}`, 'red');
process.exit(1);
}
}
// Check if we found any instruction files
if (mappings.length === 0) {
log('No GitHub Copilot instruction files found.', 'yellow');
return;
}
log(`\nTotal instruction files found: ${mappings.length}\n`, 'green');
// Check for conflicts
const claudePathMap = new Map();
for (const mapping of mappings) {
if (claudePathMap.has(mapping.claudePath)) {
const existing = claudePathMap.get(mapping.claudePath);
warnings.push(
`Conflict: Both "${path.relative(cwd, existing.instructionFile)}" (${existing.applyTo}) ` +
`and "${path.relative(cwd, mapping.instructionFile)}" (${mapping.applyTo}) ` +
`map to ${path.relative(cwd, mapping.claudePath)}`
);
} else {
claudePathMap.set(mapping.claudePath, mapping);
}
}
// Display warnings if any
if (warnings.length > 0) {
log('Warnings:', 'yellow');
warnings.forEach(w => log(` - ${w}`, 'yellow'));
log('\nDo you want to continue? (y/N): ', 'yellow');
// Read user input
process.stdin.setEncoding('utf8');
const answer = await new Promise(resolve => {
process.stdin.once('data', data => {
resolve(data.trim().toLowerCase());
});
});
if (answer !== 'y' && answer !== 'yes') {
log('Operation cancelled.', 'red');
return;
}
}
// Display mapping plan
log('Planned mappings:', 'cyan');
const uniqueMappings = Array.from(claudePathMap.values());
uniqueMappings.forEach(m => {
const relativeInstruction = path.relative(cwd, m.instructionFile);
const relativeClaude = path.relative(cwd, m.claudePath);
log(` ${relativeInstruction} (${m.applyTo}) -> ${relativeClaude}`, 'blue');
});
// Create symlinks
log('\nCreating symlinks...', 'cyan');
for (const mapping of uniqueMappings) {
await createSymlink(mapping.instructionFile, mapping.claudePath);
}
log('\nDone!', 'green');
}
// Run the script
main().catch(error => {
log(`Unexpected error: ${error.message}`, 'red');
process.exit(1);
}).then(() => {
process.exit(0);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment