Last active
August 5, 2025 04:59
-
-
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.
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 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